Skip to content

Changesets

Changesets provide field validation and change tracking for processing user input before persisting to the database. They are particularly useful for handling form submissions in web applications — cast raw string parameters into typed struct fields, validate constraints, and check for errors before inserting or updating.

A changeset wraps a data struct and tracks:

  • Changes — which fields have been modified from their defaults
  • Errors — validation failures with field name and message
  • Data — the current state of the struct with applied changes

The typical flow is: create a changeset, cast parameters into it, run validations, then check if it is valid before persisting.

Initialize a changeset with a default struct value:

const zzz_db = @import("zzz_db");
const User = struct {
id: i64 = 0,
name: []const u8 = "",
email: []const u8 = "",
age: i32 = 0,
score: f64 = 0.0,
};
var cs = zzz_db.Changeset(User).init(.{});

You can also initialize from an existing record for updates:

var cs = zzz_db.Changeset(User).init(existing_user);

The cast method takes a whitelist of allowed fields and a params object. Only whitelisted fields are accepted — this prevents mass-assignment vulnerabilities. The params object must have a get(key: []const u8) -> ?[]const u8 method.

const params = zzz_db.Changeset(User).StringMap{ .entries = &.{
.{ .key = "name", .value = "Alice" },
.{ .key = "email", .value = "alice@example.com" },
.{ .key = "age", .value = "30" },
.{ .key = "score", .value = "95.5" },
} };
var cs = zzz_db.Changeset(User).init(.{});
_ = cs.cast(&.{ "name", "email", "age", "score" }, params);

Cast automatically converts string values to the correct field type:

Field typeConversion
[]const u8Used directly as-is
i64, i32, etcParsed with std.fmt.parseInt
f64, f32Parsed with std.fmt.parseFloat
bool"true" or "1" becomes true, else false

Fields not present in params or not in the whitelist are left unchanged.

All validation methods return *Self, so they can be chained:

var cs = zzz_db.Changeset(User).init(.{});
_ = cs.cast(&.{ "name", "email", "age" }, params);
_ = cs.validateRequired(&.{ "name", "email" });
_ = cs.validateLength("name", .{ .min = 2, .max = 50 });
_ = cs.validateFormat("email", "@", "must contain @");
_ = cs.validateNumber("age", .{ .greater_than = 0, .less_than = 150 });

Checks that the specified fields have been changed (present in params) and, for string fields, are non-empty:

_ = cs.validateRequired(&.{ "name", "email" });

Error messages: "is required" (not changed) or "can't be blank" (empty string).

Validates string field length against min and/or max bounds:

_ = cs.validateLength("name", .{ .min = 2 });
_ = cs.validateLength("name", .{ .max = 100 });
_ = cs.validateLength("name", .{ .min = 2, .max = 100 });

Error messages: "is too short" or "is too long".

Checks if a string field contains a required substring:

_ = cs.validateFormat("email", "@", "must contain @");

The third argument is the error message displayed on failure.

Validates numeric fields against range bounds:

_ = cs.validateNumber("age", .{ .greater_than = 0 });
_ = cs.validateNumber("score", .{ .less_than = 100 });
_ = cs.validateNumber("age", .{ .greater_than = 0, .less_than = 150 });

Error messages: "must be greater than limit" or "must be less than limit".

Checks that a string value is one of an allowed set:

_ = cs.validateInclusion("role", &.{ "admin", "editor", "viewer" });

Error message: "is not included in the list".

Checks that a string value is NOT in a disallowed set:

_ = cs.validateExclusion("username", &.{ "admin", "root", "system" });

Error message: "is reserved".

For validations not covered by the built-in methods, pass a function:

_ = cs.validate(&struct {
fn v(c: *zzz_db.Changeset(User)) void {
if (std.mem.eql(u8, c.data.name, "test")) {
c.addError("name", "cannot be 'test'");
}
}
}.v);

These methods mark fields for constraint checks that are enforced by the database rather than in application code:

_ = cs.uniqueConstraint("email");
_ = cs.foreignKeyConstraint("department_id");

These are currently marker-only — they signal intent but actual enforcement happens at INSERT/UPDATE time. If a database constraint violation occurs, you map the database error to a changeset error in your application code.

Use putChange to set a field value directly, bypassing parameter casting:

_ = cs.putChange("name", "Bob");
_ = cs.putChange("updated_at", std.time.timestamp());

This marks the field as changed and sets the value.

Returns true if no errors have been recorded:

if (cs.valid()) {
// safe to persist cs.data
}

Returns a slice of FieldError structs:

const errors = cs.getErrors();
for (errors) |err| {
std.debug.print("{s}: {s}\n", .{ err.field, err.message });
}

Check if a specific field has any errors:

if (cs.errorsOn("email")) {
// email has validation errors
}
  1. Define the struct

    const User = struct {
    id: i64 = 0,
    name: []const u8 = "",
    email: []const u8 = "",
    age: i32 = 0,
    };
  2. Create and populate the changeset

    var cs = zzz_db.Changeset(User).init(.{});
    _ = cs.cast(&.{ "name", "email", "age" }, params);
  3. Run validations

    _ = cs.validateRequired(&.{ "name", "email" });
    _ = cs.validateLength("name", .{ .min = 2, .max = 50 });
    _ = cs.validateFormat("email", "@", "must contain @");
    _ = cs.validateNumber("age", .{ .greater_than = 0, .less_than = 150 });
  4. Check and persist

    if (cs.valid()) {
    var user = try repo.insert(User, cs.data, allocator);
    defer zzz_db.freeOne(User, &user, allocator);
    // success
    } else {
    const errors = cs.getErrors();
    // render form with errors
    }

In a zzz web handler, you can cast request parameters directly into a changeset. Any object with a get(key) -> ?[]const u8 method works as params:

fn createUser(ctx: *zzz.Context) !void {
var cs = zzz_db.Changeset(User).init(.{});
_ = cs.cast(&.{ "name", "email", "age" }, ctx.params());
_ = cs.validateRequired(&.{ "name", "email" });
_ = cs.validateFormat("email", "@", "must contain @");
if (cs.valid()) {
var user = try repo.insert(User, cs.data, ctx.allocator);
defer zzz_db.freeOne(User, &user, ctx.allocator);
ctx.redirect("/users", .see_other);
} else {
// Re-render form with cs.getErrors()
try ctx.render(FormTemplate, .ok, .{
.errors = cs.getErrors(),
.data = cs.data,
});
}
}

Changesets store up to 32 errors. If more than 32 validation failures occur, additional errors are silently dropped. This limit is sufficient for typical form validation scenarios.

  • Repository — persist changeset data with insert and update
  • Schema — define the structs that changesets validate
  • Query Builder — query records after persisting