# How to stop Claude Code from dropping your database

Ask rules on psql and mysql, a 14-line PreToolUse hook that blocks DROP, TRUNCATE and DELETE without WHERE, the commands that get past both, and the database role that matters more than either.

Start with ask rules on the database clients in `.claude/settings.json`, so no SQL runs without you seeing it:

```json
{
  "permissions": {
    "ask": ["Bash(psql *)", "Bash(mysql *)", "Bash(sqlite3 *)"],
    "deny": ["Bash(dropdb *)", "Bash(npx prisma migrate reset *)", "Bash(rails db:drop *)"]
  }
}
```

That prompts on every client call, including a harmless `SELECT`. If that is too noisy, the hook below blocks only the destructive statements. Neither one sees SQL inside a file, so the last section is about the database role.

## What a rule can see of the SQL

Rules go in `.claude/settings.json` for the repo or `~/.claude/settings.json` for every project. Claude Code checks deny, then ask, then allow ([permissions docs](https://code.claude.com/docs/en/permissions)).

A rule matches the command text, not the SQL. You can write `Bash(psql *DROP *)` and it will match `psql -c "DROP TABLE users"`. It cannot see inside `psql -f cleanup.sql`. Claude Code's docs say patterns that try to constrain arguments are fragile, and this is the kind they mean. Asking on the whole client is the rule that holds up.

An ask rule on a specific command still prompts with the sandbox on, and a matching ask rule prompts even when a hook returned allow.

## A hook that reads the statement

This hook lets a `SELECT` and a `DELETE ... WHERE` through and blocks `DROP`, `TRUNCATE` and a `DELETE` with no `WHERE`, when the command also names a database client. Save it as `~/.claude/hooks/no-destructive-sql.mjs`:

```js
#!/usr/bin/env node
let raw = "";
process.stdin.on("data", (d) => (raw += d));
process.stdin.on("end", () => {
  let cmd = "";
  try { cmd = String(JSON.parse(raw).tool_input?.command ?? ""); } catch {}
  const client = /\b(psql|pgcli|mysql|mariadb|sqlite3|duckdb|clickhouse-client|sqlcmd)\b/;
  const drop = /\bDROP\s+(TABLE|DATABASE|SCHEMA)\b|\bTRUNCATE\b/i;
  const bareDelete = cmd.split(";").some((s) => /\bDELETE\s+FROM\b/i.test(s) && !/\bWHERE\b/i.test(s));
  if (client.test(cmd) && (drop.test(cmd) || bareDelete)) {
    console.error("Blocked: destructive SQL. Stop and ask the human to run it.");
    process.exit(2);
  }
});
```

Register it in `~/.claude/settings.json`:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"$HOME/.claude/hooks/no-destructive-sql.mjs\"", "onFailure": "block" }
        ]
      }
    ]
  }
}
```

Exit code 2 is the block. Keep `onFailure: "block"`, which needs Claude Code 2.1.295 or later, so that a hook that crashes does not let the statement run ([hooks docs](https://code.claude.com/docs/en/hooks)).

## What gets past: files, ORM resets and MCP

Nine commands, and what the script did with each:

```
BLOCKED  psql -c "DROP TABLE users"
BLOCKED  mysql -e 'TRUNCATE orders'
BLOCKED  echo "DELETE FROM users;" | psql app
ran      psql -c "DELETE FROM users WHERE id = 1"
ran      psql -c "SELECT 1"
ran      psql -f cleanup.sql
ran      npx prisma migrate reset --force
ran      python manage.py flush --no-input
ran      rails db:drop
```

The last four can wipe data, and all four ran:

- `psql -f cleanup.sql` runs whatever is in the file. The hook sees a filename.
- `prisma migrate reset`, `manage.py flush` and `rails db:drop` wipe a database with no SQL in the command. Name them in `permissions.deny`, as the first block on this page does for two of them.

A fifth case never touches the shell. If the agent has a database MCP server, the SQL goes through an MCP tool call, and a `Bash` hook does not fire for it. Match the MCP tool in a second hook, or leave write tools off that server.

Give the agent a connection string for a role that has no `DROP`, `TRUNCATE` or `DELETE` on anything you care about. Keep the production URL out of `.env` files the agent can read. With that role, no spelling of the command works.

## What ACP reads

ACP's hook holds `DROP`, `TRUNCATE` and a `DELETE` with no `WHERE` when the statement is handed to `psql`, `pgcli`, `mysql`, `mariadb`, `mycli`, `sqlite3`, `duckdb`, `clickhouse-client` or `sqlcmd`. It reads the statement from a `-c` or `-e` flag, a heredoc, a here-string, or an `echo` piped into the client. In a Claude Code session you see the statement in a prompt before it runs. An unattended run is blocked at that point, and you approve on the console or from the email.

It has the same blind spots as the hook above, and one more: its built-in check looks for the client at the start of a command, so the SQL in `docker exec db psql -c "DROP TABLE users"` is not read today. SQL in a file is not read. An ORM reset against a local database is recorded in the log, not held. And this check only reads shell commands, so SQL sent through an MCP tool does not reach it.



## Frequently asked questions



## Related

- [How to stop a coding agent from dropping your production database](/blog/stop-your-agent-from-deleting-your-database)
- [How to stop Claude Code from running terraform destroy](/blog/stop-claude-code-terraform-destroy)
- [Why a Claude Code deny rule did not fire](/blog/claude-code-deny-rules-not-working)
- [Claude Code hooks reference](/blog/claude-code-hooks-reference)
