Embedded Schemas
View SourceEmbedded schemas let you nest structured data inside a parent schema, stored as JSONB in PostgreSQL. They're useful for semi-structured data that doesn't need its own table — addresses, settings, metadata, tags, etc.
Kura supports two embed types:
embeds_one— a single nested mapembeds_many— a list of nested maps
Defining Embedded Schemas
An embedded schema is a regular kura_schema module. By convention, set its table to <<"_embedded">> since it won't have its own database table:
-module(address).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([fields/0]).
fields() ->
[
#kura_field{name = street, type = string},
#kura_field{name = city, type = string},
#kura_field{name = zip, type = string}
].-module(tag).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([fields/0]).
fields() ->
[
#kura_field{name = label, type = string},
#kura_field{name = weight, type = integer}
].Adding Embeds to a Parent Schema
Export an embeds/0 callback from the parent schema returning a list of #kura_embed{} records:
-module(profile).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([table/0, fields/0, embeds/0]).
table() -> ~"profiles".
fields() ->
[
#kura_field{name = id, type = id},
#kura_field{name = name, type = string},
#kura_field{name = bio, type = text}
].
embeds() ->
[
#kura_embed{name = address, type = embeds_one, schema = address},
#kura_embed{name = tags, type = embeds_many, schema = tag}
].The corresponding migration would define these columns as JSONB:
up(Pool) ->
kura_migrator:execute(Pool,
"CREATE TABLE profiles ("
" id BIGSERIAL PRIMARY KEY,"
" name VARCHAR(255) NOT NULL,"
" bio TEXT,"
" address JSONB,"
" tags JSONB DEFAULT '[]'::jsonb,"
" inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),"
" updated_at TIMESTAMPTZ NOT NULL DEFAULT now()"
")"
).Casting Embeds
Use kura_changeset:cast_embed/2 to cast nested params through the embedded schema's fields. Validation errors from the embedded schema bubble up to the parent changeset.
embeds_one
Pass a map of params for the embedded field:
Params = #{
name => ~"Alice",
bio => ~"Developer",
address => #{street => ~"123 Main St", city => ~"Portland", zip => ~"97201"}
},
CS = kura_changeset:cast(profile, #{}, Params, [name, bio]),
CS1 = kura_changeset:cast_embed(CS, address),
%% CS1#kura_changeset.changes contains:
%% #{name => ~"Alice",
%% bio => ~"Developer",
%% address => #{street => ~"123 Main St", city => ~"Portland", zip => ~"97201"}}embeds_many
Pass a list of maps:
Params = #{
name => ~"Alice",
tags => [
#{label => ~"important", weight => 10},
#{label => ~"urgent", weight => 5}
]
},
CS = kura_changeset:cast(profile, #{}, Params, [name]),
CS1 = kura_changeset:cast_embed(CS, tags),
%% CS1#kura_changeset.changes contains:
%% #{name => ~"Alice",
%% tags => [
%% #{label => ~"important", weight => 10},
%% #{label => ~"urgent", weight => 5}
%% ]}Custom Validation with with
By default, cast_embed casts all non-virtual fields of the embedded schema. To apply custom validation or restrict fields, pass a with option — a function that takes existing data and params, and returns a changeset:
WithFun = fun(_Data, EmbedParams) ->
ChildCS = kura_changeset:cast(address, #{}, EmbedParams, [street, city]),
kura_changeset:validate_required(ChildCS, [street, city])
end,
CS1 = kura_changeset:cast_embed(CS, address, #{with => WithFun})This is useful when you want to:
- Require specific fields on the embed
- Only allow a subset of fields to be cast
- Add custom validations (length, format, etc.)
Error Handling
Type mismatches are caught automatically:
%% Passing a list where a map is expected
Params = #{name => ~"Alice", address => [1, 2, 3]},
CS = kura_changeset:cast(profile, #{}, Params, [name]),
CS1 = kura_changeset:cast_embed(CS, address),
%% CS1#kura_changeset.valid =:= false
%% CS1#kura_changeset.errors =:= [{address, ~"expected a map"}]Validation errors from embedded schemas are merged into the parent:
WithFun = fun(_Data, EmbedParams) ->
ChildCS = kura_changeset:cast(address, #{}, EmbedParams, [street, city, zip]),
kura_changeset:validate_required(ChildCS, [street, city, zip])
end,
Params = #{name => ~"Alice", address => #{street => ~"123 Main St"}},
CS = kura_changeset:cast(profile, #{}, Params, [name]),
CS1 = kura_changeset:cast_embed(CS, address, #{with => WithFun}),
%% CS1#kura_changeset.valid =:= false
%% Errors include missing city and zip from the embedded changesetStorage
Embedded schemas are stored as JSONB in PostgreSQL. Kura handles serialization automatically:
- On insert/update: Erlang maps are JSON-encoded before writing
- On load: JSONB values are decoded back into Erlang maps with atom keys
This means you can query embedded data using PostgreSQL's JSONB operators in raw SQL, while working with regular Erlang maps in application code.
When to Use Embedded Schemas
Good fit:
- Data that always belongs to the parent (addresses, settings, metadata)
- Semi-structured or variable data (form responses, config)
- Data that doesn't need independent querying or foreign keys
Consider a separate table instead when:
- The data needs its own associations
- You need to query or index the nested data frequently
- The data is shared across multiple parents