Here is the failure mode.
- You change a Drizzle schema and ask the Cursor agent to sync the database.
- It runs
npx drizzle-kit push. The command stops on a prompt, or errors because there is no terminal to prompt in. - The agent retries with
npx drizzle-kit push --force. - It finishes. A table you cared about is now empty, or a renamed column lost its data.
This post covers what push and --force actually do (checked against the source of drizzle-kit 0.31.11, the current stable release), why agents end up adding the flag, and guards you can add today, including a free Cursor rule and a hook that blocks the command.
What push does
drizzle-kit push reads your schema, introspects the live database, diffs the two and applies the SQL directly. No migration file is written. The docs recommend it for rapid prototyping, and against a local throwaway database it's fine.
Before applying anything, push looks for statements that lose data. On Postgres it counts rows and flags these when existing data is affected:
- dropping a table, column, schema or materialized view
- changing a column's type
- adding a
NOT NULLcolumn with no default - dropping a primary key
For the type change and the NOT NULL column, push doesn't only warn. It adds truncate table "<name>" cascade; to the statements it will run. cascade also empties every table with a foreign key pointing at that one.
When anything is flagged, push prints "Found data-loss statements", then "THIS ACTION WILL CAUSE DATA LOSS AND CANNOT BE REVERTED", and asks "Do you still want to push changes?" with "No, abort" as the first option.
That prompt is the safety net. --force removes it. The CLI's own description: "Auto-approve all data loss statements. Note: Data loss statements may truncate your tables and data."
Renames turn into drops
push can't tell a rename from a drop plus an add. When one column disappears and another appears in the same table, it asks: "Is display_name column in users table created or renamed from another column?" The first option is "created". Pick it and the old column, with its data, gets dropped.
--force doesn't answer that question. The rename prompt runs first. But once "created" is picked, --force approves the resulting drop with no second look.
Why agents reach for --force
These prompts are arrow-key menus. An agent running shell commands usually can't drive them. Since drizzle-kit 0.31.10, the prompt library refuses to run without a TTY and throws: "Interactive prompts require a TTY terminal (...). This can happen when running in CI, piped input, or non-interactive shells."
So plain push fails, and that failure is safe: nothing was applied. To an agent whose job is to make the command succeed, the obvious fix is the flag that skips the prompt. --force skips exactly the data-loss prompt, the one that mattered.
Two details make it worse:
-
--forcealso overridesstrict. In the source, the strict prompt only runsif (!force && strict). -
--verboseprints the SQL first, but with--forcethere's no pause between printing and running.
What drizzle-kit already gives you
-
The data-loss prompt, as long as nobody passes
--force. -
--strict, orstrict: trueindrizzle.config.ts: asks before running any push, even one with no data loss. It's in the 0.31.x CLI and config type, though the current config docs page doesn't list it. -
--verbose, orverbose: true: prints every statement before executing. -
--explain: prints the planned SQL without applying it. It's on the current push docs page and in the 1.0 release candidate, but not in 0.31.11, so check your version before relying on it. -
generate+migrate:drizzle-kit generatewrites SQL to your migrations folder, you read it, anddrizzle-kit migrateapplies it and records it in the__drizzle_migrationstable.
For anything shared (staging, prod, a teammate's database), use generate + migrate. A destructive statement in a committed .sql file shows up in review. The same statement inside a forced push only shows up in terminal scrollback.
generate asks the same rename question, so the agent can hit the same TTY error there. That's fine. The right move is to hand that one prompt to you.
The 1.0 release candidate (rc.4) changes this for agents: in non-interactive mode, push and generate stop prompting and report unresolved renames and data-loss confirmations as missing_hints, which the caller has to resolve explicitly with --hints. Until that ships as stable, plan for 0.31.x behavior.
Guard 1: a rule that makes the agent stop and ask
Save this as .cursor/rules/drizzle-push-safety.mdc. It's free, written for this post:
---
description: "Drizzle push safety. Stop and ask before any drizzle-kit command that could lose data."
alwaysApply: true
---
# Drizzle push safety
## Never run without asking first
- `drizzle-kit push --force`, including through an npm script
- `drizzle-kit push` against anything that is not a local throwaway database
- Answering a drizzle-kit rename or data-loss prompt on the user's behalf
## Before any drizzle-kit command
1. Open `drizzle.config.*` and find `dbCredentials`. If it reads an env var,
name the variable and the file that sets it. Print host and database name,
never the password.
2. If the host is not localhost or a known dev database, stop and ask.
## If push stops on a prompt or a TTY error
- Do not add `--force`. Do not pipe input into the prompt.
- Quote the prompt to the user: rename question, data-loss list,
or unique-constraint truncate question.
- Offer the tracked path:
1. `drizzle-kit generate`
2. Show the new .sql file. Point out DROP, TRUNCATE,
ALTER COLUMN ... TYPE and ADD COLUMN ... NOT NULL without DEFAULT.
3. Wait for approval, then `drizzle-kit migrate`.
- If `generate` asks the rename question, ask the user to run it
in their own terminal.
## Renames
If a column or table was renamed in the schema, say so before running
anything. Answering "created" to drizzle-kit's rename question drops
the old column and its data.
alwaysApply: true matters here. The agent decides to run push from a shell, often with no Drizzle file in context, so a glob-scoped rule might not be loaded at that moment.
Guard 2: a hook that blocks the command
A rule is text in the prompt, and a model can still talk itself past it. A Cursor hook runs outside the model. Project hooks live in .cursor/hooks.json, and a beforeShellExecution hook runs before each shell command the agent wants to execute. Its matcher is a regex tested against the full command string, so the script only runs on matches:
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{
"command": ".cursor/hooks/block-drizzle-force.sh",
"matcher": "drizzle-kit push.*--force",
"failClosed": true
}
]
}
}
#!/bin/bash
# .cursor/hooks/block-drizzle-force.sh
cat > /dev/null
cat <<'JSON'
{
"permission": "deny",
"user_message": "Blocked drizzle-kit push --force. Run it in your own terminal if you mean it.",
"agent_message": "drizzle-kit push --force is blocked in this repo. Do not retry with other flags. Run drizzle-kit generate, show the SQL, and wait for approval."
}
JSON
exit 0
Run chmod +x .cursor/hooks/block-drizzle-force.sh. Details from the hooks docs worth knowing:
- Exit code 2 also blocks, the same as returning
"permission": "deny". A script that is justcat > /dev/null; exit 2works if you don't need the messages. - Other non-zero exits, crashes and timeouts fail open by default.
failClosed: truemakes them block instead. - Project hooks only run in a trusted workspace.
- The matcher only sees the literal command. If push hides behind
npm run db:push -- --force, add|db:push.*--forceto the regex.
When you do want a forced push against your local database, run it yourself.
Guard 0: credentials
The guard nothing can argue with is still the connection string. If dbCredentials.url reads process.env.DATABASE_URL and the .env your editor loads points at a shared database, the guards above are all that stands between the agent and that data. I covered that, and the Prisma version of this problem, in Stop Cursor from running prisma migrate reset on the wrong database.
Written with AI assistance and checked against the Drizzle and Cursor docs.
Top comments (0)