Kura - Ecto-inspired database layer for Erlang

Hi all,

I’d like to share Kura, a database layer for Erlang that brings Ecto-style abstractions to pure Erlang, targeting PostgreSQL via pgo.

Why I built it

Working with PostgreSQL in Erlang has always meant either raw SQL, thin wrappers, or reaching for Elixir. I wanted the ergonomics that Ecto provides — schemas, changesets, composable
queries, migrations — but in idiomatic Erlang using OTP behaviours and records.

What it does

  • Schemas — behaviour-based definitions with type metadata
  • Changesets — cast external params, validate, track changes and errors
  • Query builder — composable, functional query construction with parameterized SQL (no string interpolation)
  • Associations — belongs_to, has_one, has_many, many_to_many with preloading
  • Embedded schemas — embeds_one/embeds_many stored as JSONB
  • Multi — atomic transaction pipelines with named steps
  • Migrations — DDL operations with automatic module-based discovery
  • Enums — atom-backed enum types stored as VARCHAR
  • Upserts — on_conflict support (nothing, replace_all, replace specific fields)
  • Telemetry — query logging with timing

Quick taste

%% Define a schema
-module(user).
-behaviour(kura_schema).
-include_lib(“kura/include/kura.hrl”).
-export([table/0, fields/0, primary_key/0]).

table() → <<“users”>>.
primary_key() → id.
fields() → 
[#kura_field{name = id, type = id, primary_key = true},
#kura_field{name = name, type = string, nullable = false},
#kura_field{name = email, type = string, nullable = false},
#kura_field{name = age, type = integer}].

%% Cast, validate, insert
CS = kura_changeset:cast(user, #{}, Params, [name, email, age]),
CS1 = kura_changeset:validate_required(CS, [name, email]),
CS2 = kura_changeset:validate_format(CS1, email, <<“@”>>),
{ok, User} = my_repo:insert(CS2).

%% Composable queries
Q = kura_query:from(user),
Q1 = kura_query:where(Q, {age, ‘>’, 18}),
Q2 = kura_query:order_by(Q1, [{name, asc}]),
Q3 = kura_query:limit(Q2, 10),
{ok, Users} = my_repo:all(Q3).

Example project

pet_store is a sample REST API built with Nova and Kura, demonstrating schemas, changesets, queries, migrations, and associations in a working application.

Rebar3 plugin

rebar3_kura auto-generates migration files from schema changes — add a field to your schema, run rebar3 compile, and the migration is created for you.

Links

Requires OTP 27+ and PostgreSQL 14+. Currently at v1.0.1.

I’d love to hear feedback, questions, or feature ideas. Happy to discuss the design choices.

15 Likes

Kura v1.9.0 — Lifecycle Hooks, Audit Trail, Multitenancy, and more

It’s been a busy few weeks since the initial release. Kura has grown from a basic Ecto-style database layer into something with real production features. Here’s what’s new in v1.9.0.

Lifecycle Hooks

Schema modules can now define before_insert, after_insert, before_update, after_update, before_delete, and after_delete callbacks. These run inside the same transaction as the operation, so returning an error rolls everything back.

-module(my_user).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").

-export([table/0, fields/0, after_insert/1]).

table() -> ~"users".
fields() -> [
    #kura_field{name = id, type = id, primary_key = true},
    #kura_field{name = name, type = string},
    #kura_field{name = email, type = string}
].

after_insert(Record) ->
    logger:info("User created: ~p", [maps:get(id, Record)]),
    {ok, Record}.

Audit Trail

Built on top of lifecycle hooks, kura_audit tracks inserts, updates, and deletes in an audit_log table. It captures who made the change (actor context), what changed (automatic diff computation for updates), and stores everything as JSON.

%% Set the actor for the current request
kura_audit:set_actor(~"user-123", #{ip => ~"10.0.0.1"}).

%% In your schema hooks:
before_update(CS) ->
    kura_audit:stash(CS),  %% capture old data for diff
    {ok, CS}.

after_insert(Record) ->
    kura_audit:log(my_repo, ?MODULE, insert, Record),
    {ok, Record}.

Migration setup is one line: kura_audit:migration_up() returns the DDL operations.

Multitenancy

Two strategies out of the box:

Schema prefix — each tenant gets a PostgreSQL schema:

kura_tenant:set_tenant({prefix, ~"tenant_abc"}).
%% All queries now target the tenant_abc schema

Attribute-based — shared table with automatic tenant_id filtering:

kura_tenant:set_tenant({attribute, {org_id, 42}}).
%% Queries get WHERE org_id = 42, inserts get org_id set automatically

Pagination

Both offset-based and cursor-based (keyset) pagination via kura_paginator:

%% Offset
{ok, Page} = kura_paginator:paginate(my_repo, Q, #{page => 2, page_size => 20}).
%% #{entries => [...], total_entries => 150, total_pages => 8, ...}

%% Cursor (efficient for large datasets)
{ok, Page} = kura_paginator:cursor_paginate(my_repo, Q, #{limit => 20, after => LastCursor}).
%% #{entries => [...], has_next => true, start_cursor => ..., end_cursor => ...}

Streaming

Process large result sets in batches without loading everything into memory, using PostgreSQL server-side cursors:

kura_stream:stream(my_repo, Q, fun(Batch) ->
    [process(Row) || Row <- Batch],
    ok
end, #{batch_size => 500}).

Optimistic Locking

Detect concurrent update conflicts with a version field:

CS = kura_changeset:cast(my_schema, Record, Params, [name, email]),
CS1 = kura_changeset:optimistic_lock(CS, lock_version),
case my_repo:update(CS1) of
    {ok, Updated} -> Updated;
    {error, stale} -> %% someone else updated first
end.

Other improvements

  • eqWAlizer clean — removed 19 of 28 type suppressions with proper typed helpers. The remaining 9 are genuine eqWAlizer limitations (pgo returns any(), union clause narrowing limits).
  • ELP lint clean — zero warnings across the entire codebase, migrated all binary strings to OTP 28 sigil syntax (~"").
  • 21 guides in the documentation covering every feature.
  • Requires OTP 28+ (up from 27).

Links

Feedback welcome as always.

4 Likes