Changesets
View SourceChangesets are the core data validation layer. They cast external parameters, track changes against existing data, validate fields, and declare database constraints.
Creating a Changeset
Use kura_changeset:cast/4 to create a changeset from a schema module, existing data, external params, and a list of allowed fields:
%% New record (empty data)
CS = kura_changeset:cast(my_user, #{}, Params, [name, email, age]).
%% Existing record (update)
CS = kura_changeset:cast(my_user, ExistingUser, Params, [name, email]).Parameters can have binary or atom keys - they are normalized automatically. Only fields in the allowed list are cast. Values are automatically cast to the type defined in the schema.
Validations
Required Fields
CS1 = kura_changeset:validate_required(CS, [name, email]).Checks that each field is present and non-blank (not undefined, null, or <<>>).
Format
CS1 = kura_changeset:validate_format(CS, email, ~"^[^@]+@[^@]+$").Validates a binary field against a regex pattern.
Length
CS1 = kura_changeset:validate_length(CS, name, [{min, 2}, {max, 100}]).
CS2 = kura_changeset:validate_length(CS, code, [{is, 6}]).Options: {min, N}, {max, N}, {is, N}. Works on binaries (byte size) and lists (length).
Number
CS1 = kura_changeset:validate_number(CS, age, [{greater_than_or_equal_to, 0}, {less_than, 150}]).Options: {greater_than, N}, {less_than, N}, {greater_than_or_equal_to, N}, {less_than_or_equal_to, N}, {equal_to, N}.
Inclusion
CS1 = kura_changeset:validate_inclusion(CS, role, [~"admin", ~"user", ~"guest"]).Custom Validation
CS1 = kura_changeset:validate_change(CS, email, fun(Val) ->
case binary:match(Val, ~"@") of
nomatch -> {error, ~"must contain @"};
_ -> ok
end
end).Constraint Declarations
Constraints map PostgreSQL constraint violations to friendly changeset errors on insert/update.
Automatic Registration via indexes/0
The recommended approach is to declare unique indexes on your schema. They are automatically registered as changeset constraints - no manual calls needed:
%% In your schema module
indexes() ->
[{[email], #{unique => true}},
{[username], #{unique => true}}].When a PG unique violation fires on users_email_index, the changeset gets {email, <<"has already been taken">>} automatically.
Unique Constraint (Manual)
For cases where you need custom constraint names or messages:
CS1 = kura_changeset:unique_constraint(CS, email).
%% With custom constraint name and message
CS1 = kura_changeset:unique_constraint(CS, email, #{
name => ~"users_email_index",
message => ~"is already registered"
}).Foreign Key Constraint
CS1 = kura_changeset:foreign_key_constraint(CS, team_id).Check Constraint
CS1 = kura_changeset:check_constraint(CS, ~"users_age_check", age, #{
message => ~"must be positive"
}).Working with Changes
%% Get a changed value
Name = kura_changeset:get_change(CS, name).
Name = kura_changeset:get_change(CS, name, ~"default").
%% Get the effective field value (changes take precedence over data)
Email = kura_changeset:get_field(CS, email).
%% Manually set a change
CS1 = kura_changeset:put_change(CS, role, ~"admin").
%% Add a custom error
CS1 = kura_changeset:add_error(CS, email, ~"is not allowed").Applying Changes
%% Merge changes into data (ignores validity)
Map = kura_changeset:apply_changes(CS).
%% Apply with action check - returns error if invalid
{ok, Map} = kura_changeset:apply_action(CS, insert).
{error, CS1} = kura_changeset:apply_action(InvalidCS, insert).Error Handling
Errors are stored as [{atom(), binary()}] in the changeset's errors field. The valid field is false when any errors are present.
case kura_repo_worker:insert(my_repo, CS) of
{ok, Record} ->
%% success
Record;
{error, #kura_changeset{errors = Errors}} ->
%% Errors is e.g. [{email, ~"has already been taken"}]
Errors
end.Dump Failures
put_change/3 writes a value into the changeset without casting it, so a value the
field's type cannot serialise only surfaces on the way to the driver. Writes fail
closed there: the field's dump error is added to the changeset and the statement is
never sent.
CS1 = kura_changeset:put_change(CS, metadata, {not_json, encodable}),
{error, #kura_changeset{errors = [{metadata, ~"cannot cast to jsonb"}]}} =
my_repo:insert(CS1).The bulk paths have no changeset to carry the error, so they return it directly:
{error, {dump_failed, metadata, ~"cannot cast to jsonb"}} =
my_repo:insert_all(my_schema, Entries).
{error, {dump_failed, metadata, ~"cannot cast to jsonb"}} =
my_repo:update_all(Query, #{metadata => {not_json, encodable}}).update_all/2 dumps its SET map through the schema's field types whenever the query's
source is a schema module, so a jsonb map or any other non-primitive reaches the
driver already serialised.
The bulk paths take raw maps rather than cast changesets, so they also accept values in
the form cast/2 accepts: ~"admin" for an {enum, _} field, ~"2026-01-01" for a
date. A dump is attempted first and the cast only runs if it fails, so the happy path
costs nothing.
A binary written to a jsonb field is treated as an already-serialised document only
when it parses as a JSON object or array; anything else is stored as a JSON string.
That keeps both directions honest: a pre-encoded document survives the bulk paths, and a
value that came back from load/2 - which decodes a stored JSON string into a bare
binary - re-encodes to the string it was, rather than turning "123" into the number
123 on the next read-modify-write. To store a scalar, pass the Erlang term: 123, or
true, not their text.
One asymmetry to know about: a jsonb column holding JSON null loads as the atom
null, and writing that back stores SQL NULL rather than JSON null. col IS NULL and
jsonb_typeof(col) = 'null' distinguish the two, so read a nullable jsonb column with
that in mind.