To keep Claude Code read-only on a production database, do two things. Give it a database user that can only read, so the database itself refuses a write. Then add a mod: a few lines of code inside Claude Code that check every database action before it runs, let SELECT, SHOW, DESCRIBE and EXPLAIN through, and block INSERT, UPDATE, DELETE, DROP and the rest with a reason Claude can act on. A line in CLAUDE.md that says "only run SELECT" is guidance. The read-only user is the guarantee, and the mod is the second layer that catches the attempt early and tells Claude why, so it can read the data or hand you the SQL instead.
Why a rule in CLAUDE.md is not enough
Claude Code often has a way into your database. It runs psql or mysql in Bash with a connection string from your environment. It calls a database MCP server. It writes a small script and runs it. Most of the time you want exactly that: read the data, find the bad row, explain the slow query.
We keep this rule ourselves. Every database is read-only for Claude, in every environment, and when a write is needed, Claude writes the SQL and a person runs it. For a long time that rule lived only in a CLAUDE.md file. That works most of the time, because the model reads the file and follows it. But it is text the model reads, not something Claude Code enforces. Nothing stops the one call where the model decides a quick UPDATE is the fastest fix.
Never use database credentials to perform any write action. [...] This holds even when the credential technically allows writes. [...] If a write seems necessary, stop and hand the exact SQL to Kristina to run herself.From Kristina's own Claude Code instructions
Since version 2.1.287, released on October 1, 2026, Claude Code has mods8: plugins whose code runs inside Claude Code and sees every event, including every tool call, before it happens.1 If you are new to them, start with Claude Code mods, explained simply. The general case, rules the model cannot skip, is in guardrails with a Claude Code mod. This post is the database version.
Layer one: a database user that can only read
Start here, before any code in Claude Code. If the credential Claude uses has no right to write, then no prompt, no script and no clever SQL can write. The database refuses. That is the only real guarantee in this post.
PostgreSQL 14 added the predefined role pg_read_all_data.9 It gives SELECT on all tables, views and sequences and USAGE on schemas, and no write rights.5 But every role also gets what PUBLIC has. Up to PostgreSQL 14 that includes CREATE on the public schema, so a "read-only" user can still create tables there. PostgreSQL 15 removed that default for new databases.10 PUBLIC also has TEMP on each database and EXECUTE on functions by default. A dedicated user for Claude can look like this. Run it yourself as an admin, not through Claude:
Three notes. The REVOKE applies to every role, not only Claude's, so check first that your app does not create objects in public with a role that relies on it. The default_transaction_read_only setting is a nice extra, but a session can turn it off with SET TRANSACTION, so it is not a lock on its own.6 The grants are the lock. On MySQL the same idea is a user with only SELECT on the schema. Even better, point that user at a read replica, so a heavy query from Claude cannot slow down production either.
Then put only this user's connection string where Claude Code can see it. If the admin credential sits in the same .env file, Claude can still find it.
Where database actions come from, and what a mod sees
If the database already says no, why add a mod? Because the mod acts earlier and talks to Claude. It blocks the write before it reaches the database, and it explains why in words Claude acts on. It also covers the cases where the read-only user is not in place yet, or where a second credential slipped into the environment. But a mod only sees what passes through a tool call, so first look at where database actions come from.
| Where it comes from | What a mod sees | What it can do |
|---|---|---|
| psql, mysql, sqlite3 in Bash | The whole command, including SQL after -c or in a heredoc | Scan the text and block a write |
| A .sql file run through a client | The file name in the command | Read the file and scan it |
| An MCP database tool | The tool name and its arguments, such as the SQL string | Block by tool name or by SQL |
| A migration or restore command | The command, such as prisma migrate deploy or pg_restore | Block by name |
| A script Claude writes and runs | Only python script.py or node script.js | Little: the SQL is inside the script |
| Your app, started by Claude against prod | Only the start command | Nothing useful |
The tool.call event fires for every tool Claude is about to use, including calls a subagent makes and calls to MCP tools.2 So the first four rows are covered well. The last two are why layer one exists.
Layer two: a mod that blocks writes
Here is the whole mod. It is a folder with three files. We validated it with claude plugin validate and ran it against eight tests with claude plugin test on Claude Code 2.1.287.
How it works, in plain words:
- Bash. If the command starts a migration or a restore, it is blocked by name. If it calls a database client, the whole command text is scanned for write words. Any
.sqlfile the command names is read and checked too. Every other Bash command goes through untouched. - MCP. The hook matches every tool whose name starts with
mcp__, using a regular expression in the matcher.2 It only acts when the server's name looks like a database. A tool named like a write, such asapply_migration, is blocked by name. Any argument named like SQL must be read-only: no write words, and every statement starts with SELECT, WITH, SHOW, DESCRIBE, EXPLAIN, VALUES or TABLE. - Blocking. The hook returns a
denywithout callingnext. The command never runs, no permission prompt appears, and Claude reads the deny text as the tool's result.2 - Failing closed. Each hook has a
.catchhandler. If the guard itself throws or times out, the call is refused instead of skipped. Without it, Claude Code would skip the broken hook and run the command.2
The deny text matters more than it looks. It does not just say no. It tells Claude not to rephrase the write to get around the guard, to use a SELECT if it needs data, and to put the change in its reply for a person to run. Claude reads the deny text as the tool result and switches to a SELECT or hands you the SQL. This is what Claude reads when it tries a DROP, copied from the test run:
To try it, load the folder for one session with claude --plugin-dir ./db-readonly-guard, or ask Claude to write a mod like it for you in a session.3 The test file and the results:
The one warning is harmless: plugin.json has no author field, which only matters for attribution when you publish the plugin.
The tricky cases
Matching SQL with patterns is easy to get almost right. These are the cases worth a test each, and how the mod above handles them.
| Case | Example | How the mod handles it |
|---|---|---|
| Upper, lower, mixed case | delete from users, Update accounts | Every pattern ignores case |
| SQL in a heredoc | psql <<'SQL' ... SQL | The heredoc is part of the Bash command text, so it is scanned |
| SQL in a file | psql -f fix.sql, cat fix.sql | psql | The file is read and checked; a file it cannot read is blocked |
| Several statements | SELECT 1; TRUNCATE orders | Every statement is checked, not just the first |
| A CTE that writes | WITH gone AS (DELETE ...) SELECT ... | Write words are found anywhere, not only at the start |
| EXPLAIN ANALYZE | EXPLAIN ANALYZE DELETE FROM orders | Blocked: EXPLAIN ANALYZE really runs the statement |
| SELECT that creates a table | SELECT * INTO backup FROM orders | Blocked: INTO is treated as a write |
| Comments | -- delete later, /* drop? */ | In plain SQL, comments are removed first so they do not count. In Bash text nothing is removed, so a comment can never hide a write; it can only cause a false block |
| Write words inside a string | WHERE note = 'drop me' | In plain SQL, quoted text is removed before the scan, so this read passes |
Notice the direction of the mistakes. For MCP SQL the mod uses an allow list: a statement must start with a read verb, so an unknown verb is blocked. For Bash it uses a block list on the raw text, which means it will sometimes block a harmless command, for example a SELECT that mentions a column called delete, or a SELECT ... FOR UPDATE, which takes row locks. For a guard, a false block is cheap. Claude reads the reason and you decide. A missed write is not.
Mod or settings hook?
To be fair: you could build most of this without a mod. A PreToolUse hook in settings.json runs a script before a tool call. It can deny the call with a reason Claude reads, through permissionDecision and permissionDecisionReason, it can rewrite the input, and its matcher accepts MCP tools such as mcp__postgres__.*.4 A sketch, not run:
Your script reads the tool call as JSON on stdin and prints the deny. If you already have one that works, keep it. Mods do not replace settings hooks, and both run side by side.7
What a mod adds for this job:
- Fail closed. A
.catchon the hook refuses the call if the guard breaks, instead of letting it through.2 - Tests without a session.
claude plugin testfires tool calls through the hook with no model, no network and no database, so every tricky case above is a test.3 - Ask instead of block. A hook can hold the call and ask you in a dialog with
$.ui.ask, for a team that wants "ask me" rather than "never".2 - Memory across calls and a view of its own. A mod keeps state, so it could count blocked writes, show them in the status line, or tighten after the first attempt. A script that runs once per call starts fresh each time.1
One ordering detail matters. PreToolUse hooks from your own settings files run after the last mod calls next. A mod that denies the call stops them from running at all. Hooks from managed settings run before any mod, and their block is final.2
What the guard cannot do
Be clear with yourself and your team about where this stops.
- It only sees tool calls. SQL built inside a script, or written by your app when Claude starts it against production, never passes through the hook as text.
- Patterns miss things. A command that decodes its SQL at run time, a stored procedure behind a harmless-looking call, or a SELECT that calls a function with side effects can all get past a text match. The read-only user still stops them.
- It can be turned off. Mods are not sandboxed and run with your permissions.
claude --safe-modeturns installed mods off for a session, anddisableAllHooksturns them off in every session.1 An administrator can deploy a guard like this as the organization's own mod, which runs before users' mods, and can stop users' own mods from loading withallowManagedModsOnly. But--safe-modestill turns off installed mods, the organization's included.7 - Other mods can see the call first. Mods run in a chain. A user's mod listed earlier in the order sees, and can change, the call before yours.2
So the order of trust is clear. The database user is the guarantee. The mod is the early, polite layer that catches the attempt, keeps Claude on track and makes the rule visible. CLAUDE.md is the note that explains the rule to the model, so it rarely needs the other two.
One last thing. A read-only Claude is a great way to look into production: what changed, which rows are wrong, why a query is slow. Fixter gives it the other half. It is monitoring for teams that build with coding agents: you send your logs and traces over standard OpenTelemetry, and Fixter finds the issues and bugs in your system and tells you what broke and why. You pull them into Claude Code over MCP, and reading them never touches your database.
Key takeaways
- A "SELECT only" rule in CLAUDE.md is guidance; nothing enforces it
- The real guarantee is a database user that can only read, ideally on a replica
- A mod checks every Bash command and MCP database call before it runs and blocks writes
- The block tells Claude why and what to do instead, rather than ending the session
- Test the tricky cases: heredocs, files, several statements, CTEs, EXPLAIN ANALYZE, comments
- A settings hook can also deny with a reason; a mod adds fail closed, tests and state
Frequently asked questions
How do I stop Claude Code from writing to my database?
Use two layers. First, give Claude Code a database user that can only read: SELECT grants and nothing else, or a read replica. That is the real guarantee, because the database itself refuses the write. Second, add a mod that checks every Bash command and every MCP database tool call before it runs, blocks writes like INSERT, UPDATE, DELETE and DROP, and tells Claude to hand the SQL to a human instead.
Can Claude Code delete my production database?
Yes, if the credential it uses can write. Claude Code runs commands and database tools with whatever access that credential has. Give it a database user that can only read, and the database refuses the DELETE or the DROP no matter how it is sent. A mod that blocks writes is the second lock: it stops the attempt before it reaches the database and tells Claude why.
Is a rule in CLAUDE.md enough to keep Claude read-only?
No. A rule in CLAUDE.md is guidance the model reads, and most of the time it follows it. But nothing in Claude Code enforces it. A mod or a settings hook is code that every tool call passes through, so the model cannot skip it. A read-only database user goes further still: it holds even if the mod is turned off or misses a statement.
Can a Claude Code mod see the SQL that Claude runs?
Only the SQL that passes through a tool call. A mod sees the full text of a Bash command, so it sees SQL passed to psql or mysql directly or in a heredoc, and it can read a .sql file the command names. It sees the arguments of an MCP database tool. It does not see SQL built inside a Python or Node script that Claude writes and runs: it only sees the command that starts the script.
What does Claude do when the mod blocks a query?
The command does not run, and Claude reads the mod's reason as the tool's result. A good reason tells Claude what to do next: use a SELECT if it needs data, or write the change as SQL or a migration in its reply for a person to review and run. Claude reads the deny text as the tool result and switches to a SELECT or hands you the SQL.
Should I use a mod or a settings hook that runs before each tool call?
Either works for a plain block. A settings hook in settings.json that runs before a tool call can already deny a Bash or MCP tool call and give Claude a reason. A mod is written in TypeScript or JavaScript inside Claude Code, can fail closed if its own code breaks, keeps state across calls, can ask you in a dialog, and is tested with claude plugin test. If you already have a working settings hook script, keep it.
Sources
- Claude Code docs, Mods overview (what a mod can reach, not sandboxed, --safe-mode and disableAllHooks, comparison with settings hooks)
- Claude Code docs, React to events with a mod (tool.call, deny, matchers, .catch to fail closed, $.ui.ask, the order mods and settings hooks run in)
- Claude Code docs, Test a mod (claude plugin test, stubs, firing tool calls)
- Claude Code docs, Hooks reference (PreToolUse permissionDecision and reason, updatedInput, MCP tool matchers)
- PostgreSQL docs, Predefined roles (pg_read_all_data)
- PostgreSQL docs, Client connection defaults (default_transaction_read_only)
- Claude Code docs, Manage mods for your organization (managed hooks run first, organization mods, allowManagedModsOnly)
- Claude Code v2.1.287 release on GitHub, October 1, 2026 (adds mods)
- PostgreSQL 14 release notes (adds pg_read_all_data and pg_write_all_data)
- PostgreSQL 15 release notes (removes PUBLIC creation permission on the public schema)