Basics
Migrations
Migrations are versioned CFCs that carry your schema forward and back. Each one subclasses wheels.migrator.Migration, exposes an up() method that applies a change and a down() that undoes it, and lives in app/migrator/migrations/ with a timestamped filename so the runner can order them. The same migrations run in every environment — development, test, CI, production — which is how a fresh clone of your repo reaches the current schema by running one command.
You’ll learn:
- The seven
wheels migratesubcommands — four for the day-to-day workflow, three for reconciliation when something’s off - How to generate a migration file and what the timestamped filename means
- The column builder methods on
createTable—string,integer,datetime,references,timestamps - How to add and remove indexes and foreign keys without dropping a table
- How to add, rename, and remove columns on an existing table
- When a migration can’t be reversed and how to signal that honestly
Running migrations
Section titled “Running migrations”Four subcommands cover the day-to-day workflow. Each one connects to your app’s datasource and reads the migration history out of wheels_migrator_versions, so the app has to be running for them to work — without a server, every wheels migrate subcommand refuses with a pointer at wheels start instead of guessing at a database. See Installing Wheels for the server setup.
wheels migrate infowheels migrate latest— apply every pending migration in order. The most common command; you’ll run it after pulling someone else’s changes.wheels migrate up— apply exactly one pending migration. Useful when you want to watch the effect of a single file.wheels migrate down— roll back the most recently applied migration. Runs thedown()method of the newest row inwheels_migrator_versions.wheels migrate info— show which migrations have run and which are still pending. Read-only; safe any time. Orphan versions (rows in the tracking table with no matching file inapp/migrator/migrations/) are flagged with[?] <version> <name> (applied <timestamp>)when the enrichedwheels_migrator_versions.name/.applied_atcolumns are populated, or[?] <version> ********** NO FILE **********(Rails-style) for legacy rows that pre-date the schema enrichment.
The runner wraps each migration in a transaction so a failing up() or down() rolls back cleanly on databases that support transactional DDL — but that support varies, so read the caution below before relying on it. One more caveat for scripts and CI: a failed migration is loud in the command output but the CLI currently still exits 0, so don’t gate a pipeline on the exit code alone (#3081).
Reconciliation subcommands
Section titled “Reconciliation subcommands”Three additional subcommands cover the cases where the tracking table and the on-disk files have drifted — most commonly when several developers share a single dev database. Read more in Shared development databases.
wheels migrate doctor— single-command health report. Lists orphans, pending local migrations, and the applied count with a human-readable summary. Read-only; never mutates.wheels migrate forget <version> --yes— removes a single row fromwheels_migrator_versionswithout runningdown(). Use this only when a peer rolled back their migration but the tracking row stayed. Refuses if the version has a matching local file (usemigrate downinstead) or if the version isn’t in the table.wheels migrate pretend <version> --yes— records a version as applied without runningup(). Use this when a peer applied the migration via direct SQL or a different tool and you need the tracking table to reflect that. Refuses if the version is already applied or if no local file matches.
Both forget and pretend print what they would do and exit if --yes is omitted; the flag is required to mutate the tracking table.
Generating a migration
Section titled “Generating a migration”The generator scaffolds a CFC with up() and down() stubs and drops it in app/migrator/migrations/ with a timestamp prefix.
wheels generate migration CreatePostsThat produces a file named like 20260420143000_CreatePosts.cfc — the name you typed is used verbatim. The prefix is YYYYMMDDHHMMSS — generated from your local clock at creation time — and the runner applies migrations in filename order, so the timestamp is what puts your change after everyone else’s. Never rename or reorder migration files after they’ve been committed: the history in wheels_migrator_versions keys on the timestamp prefix, and a renamed file looks like a brand-new migration to the runner.
Writing a migration — full example
Section titled “Writing a migration — full example”Open the generated CFC and fill in the body. A migration for a new table typically builds the table definition with createTable(), chains column methods on the returned builder, and flushes with .create(). The down() just drops the table.
component extends="wheels.migrator.Migration" hint="Create posts table" { function up() { t = createTable(name="posts"); t.string(columnNames="title", allowNull=false, limit=120); t.text(columnNames="body"); t.integer(columnNames="status", default=0); t.datetime(columnNames="publishedAt"); t.timestamps(); t.create(); }
function down() { dropTable(name="posts"); }}Three things to note:
createTable("posts")returns a table-builder object. Every column method —t.string,t.integer,t.datetime— mutates that builder. Nothing hits the database untilt.create().t.timestamps()createscreatedAt,updatedAt, and a soft-delete column (deletedAt). Don’t also add those columns by hand — you’ll get duplicate-column errors. See Models and the ORM for how Wheels populates them automatically on save.- The column-builder methods use
columnNames(plural) andallowNull. Every helper also acceptscolumnName(singular) as an alias, butcolumnNamesis the preferred form; the nullable flag is alwaysallowNull—nullis never accepted. A single call can create several same-typed columns at once:t.string(columnNames="firstName,lastName", limit=60).
Column types
Section titled “Column types”These are the column-builder methods defined on TableDefinition. Every one takes columnNames (comma-separated string), and most accept default and allowNull. A few take type-specific extras.
| Method | DB type | Common options |
|---|---|---|
t.string(columnNames=, limit=, allowNull=, default=) | VARCHAR | limit defaults to 255 |
t.char(columnNames=, limit=) | CHAR | fixed-width string |
t.text(columnNames=, size=) | TEXT / MEDIUMTEXT / LONGTEXT | size is MySQL-only |
t.integer(columnNames=, limit=, allowNull=, default=) | INT | limit is a display width, not a size bump — for BIGINT use t.bigInteger() |
t.bigInteger(columnNames=) | BIGINT | |
t.float(columnNames=) | FLOAT | |
t.decimal(columnNames=, precision=, scale=) | DECIMAL | use for money |
t.boolean(columnNames=, default=) | BOOLEAN / BIT | |
t.date(columnNames=) | DATE | |
t.datetime(columnNames=) | DATETIME / TIMESTAMP | |
t.time(columnNames=) | TIME | |
t.timestamp(columnNames=) | DATETIME | single-column version |
t.binary(columnNames=) | BLOB / VARBINARY | |
t.uniqueidentifier(columnNames=) | UNIQUEIDENTIFIER / UUID | defaults to newid() |
t.references(columnNames=, foreignKey=, onDelete=) | INT + FK | creates <name>id (or <name>_id, see below) and a FK constraint |
t.primaryKey(columnNames=, type=, autoIncrement=) | PK column | type defaults to integer; call multiple times for composite PKs (no comma-list — columnNames="a,b" is treated as a single literal column name) |
t.timestamps() | creates createdAt, updatedAt, deletedAt | no arguments |
t.references(columnNames="user") is the shortcut for the common case of a foreign key: it adds an integer column plus a FK constraint pointing at users.id. The legacy argument name referenceNames is still accepted; columnNames is the preferred modern form so the call site reads the same as every other column helper. Pass polymorphic=true to also create a type column for polymorphic associations. Pass foreignKey=false to skip the constraint and leave only the column — useful when the referenced table lives in another datasource.
The generated column suffix is controlled by the useUnderscoreReferenceColumns setting:
| Setting | t.references(columnNames="user") produces | Polymorphic user produces |
|---|---|---|
false (framework default) | userid | userid, usertype |
true (new-app template default) | user_id | user_id, user_type |
Apps generated by wheels new opt into true in config/settings.cfm so the column names match Wheels model belongsTo defaults (belongsTo("user") looks for user_id first). Existing apps with applied migrations keep the framework default of false so on-disk schemas continue to line up. Flip the setting in config/settings.cfm if you want to change the suffix the next migration produces — already-applied migrations are unaffected. Migration.cfc::addReference() and removeColumn(referenceName=) honor the same flag.
Indexes
Section titled “Indexes”Indexes don’t belong in createTable() — they’re a separate concern, and you’ll often add them to existing tables as query patterns emerge. Use addIndex() and removeIndex().
component extends="wheels.migrator.Migration" hint="Index posts publishedAt" { function up() { addIndex(table="posts", columnNames="publishedAt"); addIndex(table="posts", columnNames="userId,status", indexName="idx_posts_user_status"); }
function down() { removeIndex(table="posts", indexName="posts_publishedAt"); removeIndex(table="posts", indexName="idx_posts_user_status"); }}If you don’t pass indexName, Wheels generates one by joining the table name and first column — in this case posts_publishedAt. For multi-column indexes or when you want a predictable name, pass indexName explicitly. removeIndex always needs the name.
Pass unique=true on addIndex to create a unique constraint, which is what validatesUniquenessOf should sit on top of to race-proof the check at the database level.
Foreign keys
Section titled “Foreign keys”t.references() inside createTable() adds a foreign key as part of the CREATE TABLE statement. When you need to add a FK to an existing table — or the foreign key doesn’t follow the <parent>Id convention — use addForeignKey().
component extends="wheels.migrator.Migration" hint="Add fk: comments.postId -> posts.id" { function up() { addForeignKey( table="comments", referenceTable="posts", column="postId", referenceColumn="id" ); }
function down() { dropForeignKey(table="comments", keyName="FK_comments_posts"); }}addForeignKey() takes four required arguments and generates a constraint name like FK_<table>_<referenceTable>. The down() drops it by that generated name — dropForeignKey(table=, keyName=). There’s also addReference() / dropReference() for the convention case where the column is <singular>Id and the table is <plural>: addReference(table="comments", referenceName="post") does the same thing as the long form above.
Adding, removing, renaming columns
Section titled “Adding, removing, renaming columns”Three methods cover the common single-column changes. Each one is a method on the Migration base class — no table builder, no .create() flush.
Add a column:
component extends="wheels.migrator.Migration" hint="Add archived flag" { function up() { addColumn(table="posts", columnType="boolean", columnName="archived", default=false); }
function down() { removeColumn(table="posts", columnName="archived"); }}Rename a column:
component extends="wheels.migrator.Migration" hint="Rename body to content" { function up() { renameColumn(table="posts", columnName="body", newColumnName="content"); }
function down() { renameColumn(table="posts", columnName="content", newColumnName="body"); }}Remove a column:
component extends="wheels.migrator.Migration" hint="Remove deprecated teaser" { function up() { removeColumn(table="posts", columnName="teaser"); }
function down() { addColumn(table="posts", columnType="string", columnName="teaser", limit=255); }}addColumn takes the same type-specific options as the table-builder methods: limit, precision, scale, default, allowNull. Pass columnType="string" or "integer" or "datetime" and so on — the same type names the t.* methods use internally.
Seeding via migrations — avoid, but if you must
Section titled “Seeding via migrations — avoid, but if you must”Real seed data — users, roles, reference lookups — belongs in app/db/seeds.cfm where it runs idempotently via seedOnce(). See Seeding for that path. Don’t use migrations for seed data in long-lived projects: a INSERT in a migration runs exactly once, so editing the inserted row later means writing another migration to update it, and fresh clones of the repo end up with whatever the most recent migration wrote.
That said, you’ll occasionally want a migration to backfill data for a schema change — for example, populating a new column from an existing one. When you do, use inline SQL: execute() takes a single SQL string and nothing else — there is no parameters argument, so there’s no binding to reach for. For timestamps, use CURRENT_TIMESTAMP, which works across MySQL, PostgreSQL, SQL Server, H2, and SQLite. NOW() does not port: SQLite — the default database for wheels new apps — fails with no such function: NOW, and SQL Server has no native NOW() either. Other database-specific date functions (GETDATE(), SYSDATETIME()) don’t port at all.
component extends="wheels.migrator.Migration" hint="Seed admin role" { function up() { execute("INSERT INTO roles (name, createdAt, updatedAt) VALUES ('admin', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); }
function down() { execute("DELETE FROM roles WHERE name = 'admin'"); }}Quote string values inline; wrap each statement in execute() individually. For anything structural — building a compound object, branching on current rows — consider writing a one-off script under app/scripts/ and calling it from your deploy pipeline instead.
Reversible vs irreversible
Section titled “Reversible vs irreversible”Every up() should have a matching down() so wheels migrate down can walk the history back. Most changes are trivially reversible: createTable pairs with dropTable, addColumn with removeColumn, addIndex with removeIndex.
Some aren’t. Dropping a column with data loses that data forever; a row-level transformation (UPDATE posts SET slug = LOWER(...)) can’t be undone without the original values. In those cases, write the forward change and have down() throw — loudly — so an accidental rollback fails instead of silently corrupting the schema.
component extends="wheels.migrator.Migration" hint="Slugify posts — irreversible" { function up() { execute("UPDATE posts SET slug = LOWER(REPLACE(title, ' ', '-'))"); }
function down() { Throw( type="IrreversibleMigration", message="Cannot reverse slug transformation — original titles may differ from the generated slugs." ); }}The throw is the honest signal: “you can’t walk past this point backward.” If you need to undo the change in practice, write a new forward migration.
Filename format
Section titled “Filename format”Generated migration files are named <YYYYMMDDHHMMSS>_<NameAsTyped>.cfc — a 14-digit timestamp from your local clock, an underscore, then the name you passed to wheels generate migration, written exactly as you typed it (no snake_casing, no added suffix). The snake_case shape you may also see in app/migrator/migrations/ — 20260420143000_create_posts_table.cfc — comes from the model generator: wheels g model Post scaffolds a create_posts_table migration alongside the model. The timestamp determines the order the runner applies them. Don’t hand-edit the prefix; if you need to reorder after a merge conflict, regenerate the file and move your body into it.