Skip to content

Schema

The schema builder creates tables with Nim code. It supports SQLite and PostgreSQL and follows the database selected in your .env file.

For a new application, put table definitions in a versioned migration instead of running them on every HTTP server start.

SCHEMA CHANGES ARE CODE

Define, evolve, and reverse tables with the same fluent style.

Keep the table shape beside its rollback. Jazzy maps the schema safely to SQLite or PostgreSQL from the project environment.

createTable()alterTable()reversible migrations
src/migrations/m20260917143000_create_todos.nim
import jazzy
migration "20260917143000_create_todos":
up:
await createTable("todos")
.increments("id")
.string("title")
.boolean("completed", default = false)
.timestamps()
.softDeletes()
.execute()
down:
await dropTable("todos")

execute() performs database I/O, so it must be prefixed with await.

The code above creates a table with this shape:

ColumnPurpose
idAuto-incrementing primary key
titleRequired text
completedRequired boolean, defaulting to false
created_at / updated_atAutomatic timestamps
deleted_atEnables soft deletes

Place the builder call inside a migration’s up: section, then apply it once:

Terminal window
jazzy make:migration create_todos
jazzy migrate

Jazzy loads .env when the migration runs. No manual connectDB() call is needed in a new application. Use the same migration code for SQLite and PostgreSQL; only the .env settings change.

Builder methodSQLitePostgreSQLUse it for
.increments("id")INTEGER PRIMARY KEY AUTOINCREMENTBIGSERIAL PRIMARY KEYNumeric primary keys
.string("name")TEXTTEXTText, names, emails
.string("code", length = 20)VARCHAR(20)VARCHAR(20)Length-limited text
.integer("views")INTEGERINTEGERWhole numbers
.bigInteger("user_id")INTEGERBIGINTLarge IDs and foreign keys
.boolean("active")INTEGER (1/0)BOOLEANTrue/false values
.timestamp("published_at")DATETIMETIMESTAMPDate and time values
.timestamps()two DATETIME columnstwo TIMESTAMP columnscreated_at, updated_at
.softDeletes()deleted_at DATETIMEdeleted_at TIMESTAMPSoft deletes

Columns are required by default. Pass nullable = true for a column that may contain NULL.

await createTable("profiles")
.increments("id")
.string("display_name")
.string("bio", nullable = true)
.integer("reputation", default = 0)
.boolean("is_public", default = true)
.timestamp("published_at", nullable = true)
.execute()

For timestamps, use default = "CURRENT_TIMESTAMP" when the database should create the value itself:

await createTable("events")
.increments("id")
.timestamp("happened_at", default = "CURRENT_TIMESTAMP")
.execute()

Declare indexes and constraints alongside the table they protect. Jazzy quotes all table, column, and generated-index identifiers for both supported drivers.

await createTable("posts")
.increments("id")
.foreignId("author_id")
.constrained("users")
.onDelete("CASCADE")
.string("slug")
.string("title")
.unique("slug")
.index("author_id", "created_at")
.timestamps()
.execute()

foreignId() adds a BIGINT-compatible column. Follow it with constrained("table"), or use references("id").onTable("users") for an explicit target. onDelete() and onUpdate() accept CASCADE, RESTRICT, SET NULL, SET DEFAULT, and NO ACTION.

unique() and index() accept one or more column names. Their generated names are stable, so they can be safely used by migrations on SQLite and PostgreSQL.

The first migration creates a table. Most real migrations evolve one that is already in production: add a field, rename it, or remove an obsolete one. Always make a new migration; do not rewrite a migration that is already deployed.

Terminal window
jazzy make:migration add_due_date_to_tasks

Use alterTable() for portable column changes. The down: block must reverse the schema shape deliberately:

migration "20260918103000_add_due_date_to_tasks":
up:
await alterTable("tasks")
.addTimestamp("due_at", nullable = true)
.addBoolean("remind_owner", default = false)
.execute()
down:
# Rolling back removes the fields added above.
await alterTable("tasks")
.dropColumn("remind_owner")
.dropColumn("due_at")
.execute()
migration "20260918104500_rename_users_name":
up:
await renameColumn("users", "name", "full_name")
down:
await renameColumn("users", "full_name", "name")
migration "20260918110000_remove_tasks_legacy_note":
up:
await alterTable("tasks").dropColumn("legacy_note").execute()
down:
# The column shape comes back; deleted values cannot be recovered.
await alterTable("tasks")
.addString("legacy_note", nullable = true)
.execute()

dropColumn() permanently removes the stored values. Back up or copy data to a replacement column before dropping a field if you may need those values.

Use await renameTable("old_name", "new_name") for a table rename. To remove a whole table, use await dropTable("temporary_imports") in up: and recreate its complete schema in down: if rollback must restore the table definition. Dropping a table, like dropping a column, does not preserve its rows.

For a portable operation the current builder does not expose yet, use reviewed static DDL with DB.rawExec() in the migration.

createTable() is strict by default. This is deliberate: a migration must fail if a table of the same name already exists, rather than being recorded as applied against an unknown schema. For intentionally idempotent local setup, opt in to IF NOT EXISTS explicitly.

await createTable("audit_logs")
.ifNotExists()
.increments("id")
.string("message")
.execute()

Jazzy quotes names generated by the schema builder, so a reserved word such as order can be used as a table name:

await createTable("order")
.increments("id")
.string("status")
.execute()

The schema builder describes table changes. Migrations decide when a change runs, record it, and provide a rollback path. Use both together for production changes.

Once the table exists, use the Database query builder or the optional ORM to read and write rows.