Changesets and Validation

In the previous chapter we defined schemas and generated migrations. Before we can insert or update data, we need to validate it. Kura uses changesets — a data structure that tracks what fields changed, validates them, and accumulates errors. No exceptions, no side effects — just data in, data out.

The changeset concept

A changeset takes three inputs:

  1. Data — the existing record (or #{} for a new one)
  2. Params — the incoming data (typically from a request body)
  3. Allowed fields — which params are permitted (everything else is ignored)

It produces a #kura_changeset{} record with:

  • changes — a map of field → new value
  • errors — a list of {field, message} tuples
  • validtrue or false

Adding changeset functions to schemas

Let's add a changeset/2 function to the post schema. Update src/schemas/post.erl:

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

-export([table/0, fields/0, primary_key/0, changeset/2]).

table() -> <<"posts">>.

primary_key() -> id.

fields() ->
    [
        #kura_field{name = id, type = id, primary_key = true, nullable = false},
        #kura_field{name = title, type = string, nullable = false},
        #kura_field{name = body, type = text},
        #kura_field{name = status, type = {enum, [draft, published, archived]}, default = <<"draft">>},
        #kura_field{name = user_id, type = integer},
        #kura_field{name = inserted_at, type = utc_datetime},
        #kura_field{name = updated_at, type = utc_datetime}
    ].

changeset(Data, Params) ->
    CS = kura_changeset:cast(post, Data, Params, [title, body, status, user_id]),
    CS1 = kura_changeset:validate_required(CS, [title, body]),
    CS2 = kura_changeset:validate_length(CS1, title, [{min, 3}, {max, 200}]),
    kura_changeset:validate_inclusion(CS2, status, [draft, published, archived]).

Here is what each step does:

  1. cast/4 — takes the schema module, existing data, incoming params, and a list of allowed fields. It converts param values to the correct Erlang types (binaries to atoms for enums, binaries to integers for IDs, etc.) and puts them in changes.
  2. validate_required/2 — ensures the listed fields are present and non-empty.
  3. validate_length/3 — checks string length constraints.
  4. validate_inclusion/3 — ensures the value is one of the allowed options.

User changeset with format and unique constraints

Update src/schemas/user.erl:

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

-export([table/0, fields/0, primary_key/0, changeset/2]).

table() -> <<"users">>.

primary_key() -> id.

fields() ->
    [
        #kura_field{name = id, type = id, primary_key = true, nullable = false},
        #kura_field{name = username, type = string, nullable = false},
        #kura_field{name = email, type = string, nullable = false},
        #kura_field{name = password_hash, type = string, nullable = false},
        #kura_field{name = inserted_at, type = utc_datetime},
        #kura_field{name = updated_at, type = utc_datetime}
    ].

changeset(Data, Params) ->
    CS = kura_changeset:cast(user, Data, Params, [username, email, password_hash]),
    CS1 = kura_changeset:validate_required(CS, [username, email, password_hash]),
    CS2 = kura_changeset:validate_format(CS1, email, "^[^@]+@[^@]+\\.[^@]+$"),
    CS3 = kura_changeset:validate_length(CS2, username, [{min, 2}, {max, 50}]),
    CS4 = kura_changeset:unique_constraint(CS3, email),
    kura_changeset:unique_constraint(CS4, username).

New validations:

  • validate_format/3 — checks the value against a regex. The email regex ensures it has @ and a domain.
  • unique_constraint/2 — declares that this field has a unique index in the database. If an insert/update violates the constraint, Kura maps the PostgreSQL error to a friendly changeset error instead of crashing.

Info

unique_constraint does not check uniqueness in Erlang — it tells Kura how to handle the PostgreSQL unique violation error. You still need a unique index on the column, which you would add to a migration.

Changeset errors as structured data

Errors are a list of {Field, Message} tuples on the changeset:

1> CS = post:changeset(#{}, #{}).
#kura_changeset{valid = false, errors = [{title, <<"can't be blank">>},
                                          {body, <<"can't be blank">>}], ...}

2> CS#kura_changeset.valid.
false

3> CS#kura_changeset.errors.
[{title, <<"can't be blank">>}, {body, <<"can't be blank">>}]
4> CS2 = post:changeset(#{}, #{<<"title">> => <<"Hi">>, <<"body">> => <<"Hello">>}).
#kura_changeset{valid = false, errors = [{title, <<"must be at least 3 characters">>}], ...}

Rendering errors in JSON responses

Convert changeset errors to a JSON-friendly map:

changeset_errors_to_json(#kura_changeset{errors = Errors}) ->
    maps:from_list([{atom_to_binary(Field), Msg} || {Field, Msg} <- Errors]).

Use it in controllers:

create(#{params := Params}) ->
    CS = post:changeset(#{}, Params),
    case blog_repo:insert(CS) of
        {ok, Post} ->
            {json, 201, #{}, post_to_json(Post)};
        {error, #kura_changeset{} = CS1} ->
            {json, 422, #{}, #{errors => changeset_errors_to_json(CS1)}}
    end.

The response looks like:

{
  "errors": {
    "title": "can't be blank",
    "body": "can't be blank"
  }
}

Available validation functions

FunctionPurpose
validate_required(CS, Fields)Fields must be present and non-empty
validate_format(CS, Field, Regex)Value must match the regex
validate_length(CS, Field, Opts)String length: [{min,N}, {max,N}, {is,N}]
validate_number(CS, Field, Opts)Number range: [{greater_than,N}, {less_than,N}]
validate_inclusion(CS, Field, List)Value must be in the list
validate_change(CS, Field, Fun)Custom validation: fun(Val) -> ok | {error, Msg}
unique_constraint(CS, Field)Map PG unique violation to a changeset error
foreign_key_constraint(CS, Field)Map PG FK violation to a changeset error
check_constraint(CS, Name, Field, Opts)Map PG check constraint to a changeset error

Schemaless changesets

For validating data that does not map to a database table (like search filters or contact forms), pass a types map instead of a schema module:

Types = #{query => string, page => integer, per_page => integer},
CS = kura_changeset:cast(Types, #{}, Params, [query, page, per_page]),
CS1 = kura_changeset:validate_required(CS, [query]),
CS2 = kura_changeset:validate_number(CS1, per_page, [{greater_than, 0}, {less_than, 101}]).

Schemaless changesets cannot be persisted via the repo — they are for validation only.


Validations are declarative and composable. Errors are data, not exceptions. Now let's use changesets to perform CRUD operations with the repository.