This guide explains how to manage database schema changes and migrations using FluentORM.
FluentORM includes a built-in migration system that automatically generates SQL migration files based on your TableSchema definitions and provides a command-line tool to execute them safely.
⚠️ Note: The migration system is currently under development and has not been fully tested in production environments. Use with caution and always backup your database before running migrations.
Create schema files in the schemas/ directory using the XX_tablename.zig naming convention:
schemas/
├── 01_users.zig
├── 02_posts.zig
└── 03_comments.zig
The number prefix determines the execution order, ensuring tables are created in dependency order (e.g., users before posts if posts references users).
Run the two-step generation process:
# Step 1: Generate registry and runner
zig build generate
# Step 2: Generate model files and SQL migrations
zig build generate-modelsThis creates SQL migration files in the migrations/ directory with timestamps:
migrations/
├── 1764673549_create_users.sql
├── 1764673549_create_users_down.sql
├── 1764673550_create_posts.sql
├── 1764673550_create_posts_down.sql
└── ...
Each table gets both an "up" migration (creates the table) and a "down" migration (drops the table).
Set environment variables for database connection:
export FLUENT_DB_HOST=localhost
export FLUENT_DB_PORT=5432
export FLUENT_DB_NAME=my_database
export FLUENT_DB_USER=my_user
export FLUENT_DB_PASSWORD=my_passwordUse the built-in migration runner:
# Run all pending migrations
zig build migrate
# Or run specific commands
zig build migrate-up # Run pending migrations
zig build migrate-status # Show migration status
zig build migrate-down # Rollback last migrationBy default, migrations are stored in ./migrations/. To use a different directory:
# Via build option
zig build -Dmigrations-dir=./db/migrations migrate
# Via command line
zig build migrate -- --migrations-dir ./db/migrationsThe migration runner supports several commands:
Runs all pending migrations in timestamp order.
zig build migrateShows the current migration status:
zig build migrate-statusOutput:
Applied migrations:
✓ 1764673549_create_users
✓ 1764673550_create_posts
Pending migrations:
○ 1764673551_add_comments
Rolls back the last applied migration.
zig build migrate-downGet detailed help:
zig build run fluent-migrate -- --helpMigrations use timestamp-based naming for proper ordering:
{timestamp}_{description}.sql # Up migration
{timestamp}_{description}_down.sql # Down migration
Example:
1764673549_create_users.sql
1764673549_create_users_down.sql
1764673550_create_posts.sql
1764673550_create_posts_down.sql
The timestamp ensures migrations run in the correct order, and the description makes it clear what each migration does.
Each up migration includes table creation and constraints:
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at TIMESTAMP
);
-- Create indexes
CREATE UNIQUE INDEX idx_users_email ON users(email);Down migrations reverse the changes:
-- Drop table (cascade removes constraints)
DROP TABLE IF EXISTS users CASCADE;FluentORM follows a schema-first approach. When you need to modify your database structure, you should always update your schema files and regenerate models. Never manually edit SQL files or run ad-hoc ALTER statements.
- Modify your schema file (
schemas/XX_tablename.zig) - Regenerate models:
zig build generate && zig build generate-models - Apply the regenerated migrations using the migration runner
Add the new field to your schema file:
// schemas/01_users.zig
pub fn build(t: *TableSchema) void {
// ... existing fields ...
// Add a new field
t.string(.{
.name = "phone",
.nullable = true,
.create_input = .optional,
});
}Regenerate and migrate:
zig build generate
zig build generate-models
zig build migrateUse the alterField() method to modify properties of an existing field:
// schemas/01_users.zig
pub fn build(t: *TableSchema) void {
// Original field definition
t.string(.{
.name = "bio",
.nullable = false,
});
// Later, alter it to be optional and redacted
t.alterField(.{
.name = "bio",
.nullable = true,
.create_input = .optional,
.redacted = true,
});
}alterField() Options:
| Option | Type | Description |
|---|---|---|
name |
[]const u8 |
Required: Name of field to alter |
type |
?FieldType |
Change the field type |
primary_key |
?bool |
Change primary key status |
unique |
?bool |
Change unique constraint |
not_null |
?bool |
Change NOT NULL constraint |
create_input |
?InputMode |
Change create input mode |
update_input |
?bool |
Change update input inclusion |
redacted |
?bool |
Change JSON response redaction |
default_value |
?[]const u8 |
Change SQL default value |
auto_generated |
?bool |
Change auto-generation status |
auto_generate_type |
?AutoGenerateType |
Change auto-generation type |
Use alterFields() to modify multiple fields at once:
t.alterFields(&.{
.{ .name = "email", .unique = true },
.{ .name = "bio", .nullable = true, .redacted = true },
.{ .name = "password_hash", .redacted = true },
});Remove the field definition from your schema file, then regenerate and migrate.
zig build generate-models.
✅ Always modify your schema files first, then regenerate models and apply migrations.
The migration system includes several safety features:
Each migration file is hashed, and the hash is stored in the database. If a migration file changes after being applied, the system will detect the mismatch and refuse to run.
Each migration runs in a database transaction. If it fails, all changes are rolled back.
Migrations run in timestamp order, ensuring tables are created before their foreign keys.
You can rollback the last migration if something goes wrong.
Commit your schema files to version control. The generated migration files can be regenerated:
git add schemas/
git commit -m "Add phone field to users schema"Never bypass the schema. All database structure should be defined in your schemas/ directory.
Always backup your database before applying schema changes:
pg_dump -U postgres mydb > backup_$(date +%Y%m%d_%H%M%S).sqlNever run untested migrations directly in production:
- Test locally
- Test in staging environment
- Deploy to production
Use clear descriptions in your schema filenames:
01_users.zig # ✅ Clear
01_u.zig # ❌ Unclear
01_user_table.zig # ❌ Redundant
When changing field properties, use alterField() instead of modifying the original field definition:
// Original
t.string(.{ .name = "bio" });
// Later modification - clear intent
t.alterField(.{ .name = "bio", .nullable = true, .redacted = true });Problem: Cannot connect to PostgreSQL.
Solution: Check environment variables and ensure PostgreSQL is running:
# Test connection
psql -U $FLUENT_DB_USER -d $FLUENT_DB_NAME -h $FLUENT_DB_HOST -p $FLUENT_DB_PORTProblem: Migration file was modified after being applied.
Solution: Never modify migration files after they've been applied. If you need to change something, create a new migration.
Problem: Foreign key references a table that doesn't exist yet.
Solution: Ensure schema files are numbered correctly for dependency order.
Problem: gen_random_uuid() function not found.
Solution: The migration system automatically creates the UUID extension. If it fails, ensure your database user has the necessary permissions.
Problem: _fluent_migrations table doesn't exist.
Solution: The migration runner creates this table automatically on first run. Ensure your database user has table creation permissions.
⚠️ Development Status: The migration system is currently under active development and has not been fully tested in production environments. While it includes safety features like checksum verification and transactional execution, it should be used with caution.Known Limitations:
- Complex schema changes (renaming fields, changing types) may require manual intervention
- No support for data migrations (changing existing data during schema changes)
- Rollback only supports the last migration
Roadmap:
- Multi-step rollbacks
- Data migration support
- Schema diffing and automatic migration generation
- Migration testing framework
- See GETTING_STARTED.md for complete setup instructions
- See SCHEMA.md for defining table schemas
- See BASE_MODEL.md for using generated models
- See RELATIONSHIPS.md for managing foreign keys
Create schema files in the schemas/ directory using the XX_tablename.zig naming convention:
schemas/
├── 01_users.zig
├── 02_posts.zig
└── 03_comments.zig
The number prefix determines the execution order, ensuring tables are created in dependency order (e.g., users before posts if posts references users).
Run the two-step generation process:
# Step 1: Generate registry and runner
zig build generate
# Step 2: Generate model files and SQL migrations
zig build generate-modelsThis creates SQL migration files in the migrations/ directory with a specific structure:
migrations/
├── tables/
│ ├── 01_users.sql
│ ├── 02_posts.sql
│ └── 03_comments.sql
└── constraints/
├── 02_posts_fk.sql
└── 03_comments_fk.sql
- tables/: CREATE TABLE statements (run first)
- constraints/: Foreign key constraints (run after all tables exist)
Use psql or your favorite PostgreSQL client to run migrations in order:
# First: Create all tables
psql -U postgres -d mydb -f migrations/tables/01_users.sql
psql -U postgres -d mydb -f migrations/tables/02_posts.sql
psql -U postgres -d mydb -f migrations/tables/03_comments.sql
# Then: Add foreign key constraints
psql -U postgres -d mydb -f migrations/constraints/02_posts_fk.sql
psql -U postgres -d mydb -f migrations/constraints/03_comments_fk.sqlOr run all migrations with a shell script:
#!/bin/bash
DB_NAME="mydb"
DB_USER="postgres"
# Run table migrations in order
for f in migrations/tables/*.sql; do
echo "Running $f..."
psql -U $DB_USER -d $DB_NAME -f "$f"
done
# Run constraint migrations in order
for f in migrations/constraints/*.sql; do
echo "Running $f..."
psql -U $DB_USER -d $DB_NAME -f "$f"
done
echo "Migrations complete!"Each table migration includes:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at TIMESTAMP
);Foreign key constraints are generated separately:
ALTER TABLE posts
ADD CONSTRAINT fk_posts_user_id
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE;This separation ensures tables exist before constraints reference them.
FluentORM follows a schema-first approach. When you need to modify your database structure, you should always update your schema files and regenerate models. Never manually edit SQL files or run ad-hoc ALTER statements, as this will cause your generated models to be out of sync with your database.
- Modify your schema file (
schemas/XX_tablename.zig) - Regenerate models:
zig build generate && zig build generate-models - Apply the regenerated migrations to your database
Add the new field to your schema file:
// schemas/01_users.zig
pub fn build(t: *TableSchema) void {
// ... existing fields ...
// Add a new field
t.string(.{
.name = "phone",
.nullable = true,
.create_input = .optional,
});
}Regenerate:
zig build generate
zig build generate-modelsThen re-run the generated table migration to apply the changes.
Use the alterField() method to modify properties of an existing field. This is useful when you want to change constraints, input modes, or other properties without redefining the entire field.
// schemas/01_users.zig
pub fn build(t: *TableSchema) void {
// Original field definition
t.string(.{
.name = "bio",
.nullable = false,
});
// Later, alter it to be optional and redacted
t.alterField(.{
.name = "bio",
.nullable = true,
.create_input = .optional,
.redacted = true,
});
}alterField() Options:
| Option | Type | Description |
|---|---|---|
name |
[]const u8 |
Required: Name of field to alter |
type |
?FieldType |
Change the field type |
primary_key |
?bool |
Change primary key status |
unique |
?bool |
Change unique constraint |
not_null |
?bool |
Change NOT NULL constraint |
create_input |
?InputMode |
Change create input mode |
update_input |
?bool |
Change update input inclusion |
redacted |
?bool |
Change JSON response redaction |
default_value |
?[]const u8 |
Change SQL default value |
auto_generated |
?bool |
Change auto-generation status |
auto_generate_type |
?AutoGenerateType |
Change auto-generation type |
Only specify the properties you want to change; others retain their original values.
Use alterFields() to modify multiple fields at once:
t.alterFields(&.{
.{ .name = "email", .unique = true },
.{ .name = "bio", .nullable = true, .redacted = true },
.{ .name = "password_hash", .redacted = true },
});Remove the field definition from your schema file, then regenerate:
// schemas/01_users.zig
pub fn build(t: *TableSchema) void {
// Remove the field you no longer need by deleting its definition
// t.string(.{ .name = "phone" }); // <-- Delete this line
}Then regenerate and re-run the migrations.
Coming Soon: Field renaming via
t.renameField()is planned for a future release.
Currently, FluentORM does not support direct field renaming. This feature is on the roadmap.
migrations/tables/ or migrations/constraints/. These are regenerated each time you run zig build generate-models.
✅ Always modify your schema files first, then regenerate models and apply migrations.
Commit your schema files to version control. The generated migration files can be regenerated:
git add schemas/
git commit -m "Add phone field to users schema"Never bypass the schema. All database structure should be defined in your schemas/ directory:
schemas/
├── 01_users.zig # Users table definition
├── 02_posts.zig # Posts table definition
├── 03_comments.zig # Comments table definition
├── registry.zig # Auto-generated
└── runner.zig # Auto-generated
Use numbered prefixes to ensure correct migration order:
01_users.zig- No dependencies02_posts.zig- Depends on users (hasuser_idFK)03_comments.zig- Depends on users and posts
Always backup your database before applying schema changes:
pg_dump -U postgres mydb > backup_$(date +%Y%m%d_%H%M%S).sqlNever run untested migrations directly in production:
- Test locally
- Test in staging environment
- Deploy to production
When changing field properties, use alterField() instead of modifying the original field definition. This makes changes explicit and trackable:
// Original
t.string(.{ .name = "bio" });
// Later modification - clear intent
t.alterField(.{ .name = "bio", .nullable = true, .redacted = true });Generated models include these DDL methods from the BaseModel:
const exists = try Users.tableExists(&pool);
if (exists) {
std.debug.print("Table exists\n", .{});
}Returns true if the table exists in the database.
try Users.truncate(&pool);Removes all rows but keeps the table structure.
Warning: This permanently deletes all data in the table.
Note:
createTable(),dropTable(), andcreateIndexes()methods are not currently available. Use the generated SQL files orpsqlcommands instead.
Problem: Foreign key or unique constraint fails.
Solution: Ensure tables are created in dependency order. Check the number prefix in schema filenames.
Problem: Trying to add a column that already exists.
Solution: The generated migrations use CREATE TABLE IF NOT EXISTS. If you need to modify an existing table, update your schema and regenerate.
Problem: gen_random_uuid() function not found.
Solution: The generated migrations include CREATE EXTENSION IF NOT EXISTS "uuid-ossp";. Ensure the migration file is executed properly.
Problem: Cannot connect to PostgreSQL.
Solution: Check connection settings, ensure PostgreSQL is running, and verify credentials.
- See SCHEMA.md for defining table schemas
- See BASE_MODEL.md for using generated models
- See RELATIONSHIPS.md for managing foreign keys