<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: sendtoshailesh</title>
    <description>The latest articles on DEV Community by sendtoshailesh (@sendtoshailesh).</description>
    <link>https://dev.to/sendtoshailesh</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F388399%2Fbb93ad06-3c0c-4e8a-82d5-e75b70cf52ff.png</url>
      <title>DEV Community: sendtoshailesh</title>
      <link>https://dev.to/sendtoshailesh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sendtoshailesh"/>
    <language>en</language>
    <item>
      <title>11 Claude Code Gotchas That Quietly Skip Your Guardrails</title>
      <dc:creator>sendtoshailesh</dc:creator>
      <pubDate>Tue, 29 Sep 2026 14:56:08 +0000</pubDate>
      <link>https://dev.to/sendtoshailesh/11-claude-code-gotchas-that-quietly-skip-your-guardrails-5c8p</link>
      <guid>https://dev.to/sendtoshailesh/11-claude-code-gotchas-that-quietly-skip-your-guardrails-5c8p</guid>
      <description>&lt;p&gt;Claude Code is now the harness a lot of AI engineers run agentic dev workflows through — interactive, headless with &lt;code&gt;-p&lt;/code&gt;, through the Agent SDK, in CI, with subagents and a stack of MCP servers. The failure modes that matter are almost never in the setup guide. They're the quiet ones: an &lt;code&gt;ask&lt;/code&gt; rule that never prompts, a checked-in &lt;code&gt;.mcp.json&lt;/code&gt; that loads with no approval, a hook that fails open, an MCP call that can hang for a day.&lt;/p&gt;

&lt;p&gt;Eleven gotchas a 3-year practitioner wouldn't already know (86 raw fragments → 22 candidates → 15 validated → 11 published). &lt;strong&gt;Numbers are the severity × rarity rank; sections group by theme&lt;/strong&gt; (so the numbering is intentionally non-sequential). Each has a detection and a fix. Every threshold, TTL, timeout, block count, and cost figure is &lt;strong&gt;as of Claude Code v2.1.283 / September 2026&lt;/strong&gt; — re-verify before hard-coding. The GH#46829 20–32% and GH#97622 ~50,000-char figures are &lt;strong&gt;community-reported, not confirmed by Anthropic&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR — the 11, in rank order
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Gotcha&lt;/th&gt;
&lt;th&gt;One-line takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;permissions.ask&lt;/code&gt; can run without asking&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;deny&lt;/code&gt; for anything safety-critical; canary-test &lt;code&gt;ask&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;-p&lt;/code&gt; / SDK / cloud / &lt;code&gt;--bare&lt;/code&gt; skip safety layers&lt;/td&gt;
&lt;td&gt;Unapproved &lt;code&gt;.mcp.json&lt;/code&gt; loads headless — use &lt;code&gt;--strict-mcp-config&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;CLAUDE.md enforces nothing; hooks fail open&lt;/td&gt;
&lt;td&gt;Hard rules go in &lt;code&gt;permissions&lt;/code&gt;; hooks must &lt;code&gt;exit 2&lt;/code&gt; and anchor matchers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MCP_TOOL_TIMEOUT&lt;/code&gt; defaults to ~28h&lt;/td&gt;
&lt;td&gt;Set it explicitly, especially for stdio/WebSocket servers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Auto mode is now the default&lt;/td&gt;
&lt;td&gt;Pin &lt;code&gt;permissions.defaultMode&lt;/code&gt;; the 3/20 block breaker isn't tunable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Subagents get a session-start CLAUDE.md snapshot&lt;/td&gt;
&lt;td&gt;Restate must-follow rules in the delegation prompt.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--max-budget-usd&lt;/code&gt; forgets resumed spend&lt;/td&gt;
&lt;td&gt;Track cumulative spend externally.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;rm -rf&lt;/code&gt; countdown resolves to deny&lt;/td&gt;
&lt;td&gt;Unattended auto/bypass pipelines should expect the deny.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Cache TTL drops from 1h to 5m&lt;/td&gt;
&lt;td&gt;Pin &lt;code&gt;CLAUDE_CODE_PROMPT_CACHE_TTL&lt;/code&gt; / &lt;code&gt;CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Four config knobs don't do what their names say&lt;/td&gt;
&lt;td&gt;Raise the compact window with &lt;code&gt;/autocompact&lt;/code&gt;, not the env var.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;MCP output over the cap goes to a file&lt;/td&gt;
&lt;td&gt;A tool's own &lt;code&gt;maxResultSizeChars&lt;/code&gt; beats &lt;code&gt;MAX_MCP_OUTPUT_TOKENS&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;All values as of v2.1.283 / Sep 2026.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The trust-boundary three
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;permissions.ask&lt;/code&gt; has three different ways to not actually ask
&lt;/h3&gt;

&lt;p&gt;At least three independently reported paths let an ask-listed command run with no prompt: in default/no-sandbox mode a visible &lt;code&gt;Bash(conda run:*)&lt;/code&gt; ask rule ran unprompted (&lt;a href="https://github.com/anthropics/claude-code/issues/79771" rel="noopener noreferrer"&gt;GH#79771&lt;/a&gt;, open) while &lt;code&gt;/sandbox&lt;/code&gt; with &lt;code&gt;autoAllowBashIfSandboxed:false&lt;/code&gt; did enforce it; auto mode ignores &lt;code&gt;permissions.ask&lt;/code&gt; while honoring &lt;code&gt;allow&lt;/code&gt;/&lt;code&gt;deny&lt;/code&gt; (&lt;a href="https://github.com/anthropics/claude-code/issues/42797" rel="noopener noreferrer"&gt;GH#42797&lt;/a&gt;, closed, no stated fix); and a transiently invalid &lt;code&gt;settings.json&lt;/code&gt; silently drops deny &lt;strong&gt;and&lt;/strong&gt; ask enforcement in &lt;code&gt;-p&lt;/code&gt;/stream-json (&lt;a href="https://github.com/anthropics/claude-code/issues/78764" rel="noopener noreferrer"&gt;GH#78764&lt;/a&gt;, closed "not planned," labeled reproduced). No stated fix as of v2.1.283.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection:&lt;/strong&gt; put a harmless canary on &lt;code&gt;permissions.ask&lt;/code&gt; and confirm a prompt actually appears — don't trust &lt;code&gt;/permissions&lt;/code&gt; listing it. In a test project only, break &lt;code&gt;settings.json&lt;/code&gt; mid-run under &lt;code&gt;-p&lt;/code&gt; and confirm denied commands stay denied. &lt;strong&gt;Fix:&lt;/strong&gt; use &lt;code&gt;permissions.deny&lt;/code&gt; for anything safety-critical; where &lt;code&gt;ask&lt;/code&gt; is required, also enable &lt;code&gt;/sandbox&lt;/code&gt; with &lt;code&gt;autoAllowBashIfSandboxed:false&lt;/code&gt;; never hand-edit a live &lt;code&gt;settings.json&lt;/code&gt; mid-session.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;-p&lt;/code&gt;, the Agent SDK, cloud sessions, and &lt;code&gt;--bare&lt;/code&gt; all quietly skip a safety layer
&lt;/h3&gt;

&lt;p&gt;Trust verification is disabled under &lt;code&gt;-p&lt;/code&gt;. Project &lt;code&gt;.mcp.json&lt;/code&gt; servers load with no approval prompt in &lt;code&gt;-p&lt;/code&gt;, the Agent SDK, and cloud sessions — and in any &lt;code&gt;bypassPermissions&lt;/code&gt; session with &lt;code&gt;skipDangerousModePermissionPrompt&lt;/code&gt; set. &lt;code&gt;--bare&lt;/code&gt; skips hooks, skills, commands, subagents, plugins, MCP, auto memory, and CLAUDE.md, and sets &lt;code&gt;CLAUDE_CODE_SIMPLE&lt;/code&gt; (skills in an &lt;code&gt;--add-dir&lt;/code&gt; directory still load). Plain &lt;code&gt;--continue&lt;/code&gt; skips &lt;code&gt;-p&lt;/code&gt;/SDK/&lt;code&gt;/loop&lt;/code&gt;-first sessions unless you run &lt;code&gt;claude -p --continue&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection:&lt;/strong&gt; audit CI target repos for a checked-in &lt;code&gt;.mcp.json&lt;/code&gt;; after a &lt;code&gt;-p&lt;/code&gt; session, &lt;code&gt;claude mcp list&lt;/code&gt;. &lt;strong&gt;Fix:&lt;/strong&gt; &lt;code&gt;--strict-mcp-config&lt;/code&gt;, &lt;code&gt;disabledMcpjsonServers&lt;/code&gt;, or &lt;code&gt;--setting-sources&lt;/code&gt; for &lt;code&gt;-p&lt;/code&gt;/SDK/CI runs; treat every headless target repo as pre-vetted; don't use &lt;code&gt;--bare&lt;/code&gt; where hooks are your guardrail. &lt;em&gt;(Official docs — single publisher.)&lt;/em&gt; (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/security" rel="noopener noreferrer"&gt;security&lt;/a&gt;)&lt;/p&gt;

&lt;h3&gt;
  
  
  3. CLAUDE.md doesn't enforce anything — and PreToolUse hooks fail open
&lt;/h3&gt;

&lt;p&gt;The docs: &lt;em&gt;"CLAUDE.md instructions shape Claude's behavior but are not a hard enforcement layer."&lt;/em&gt; Enforcement is &lt;code&gt;permissions&lt;/code&gt; allow/ask/deny plus PreToolUse hooks, and deny/ask beat a hook's decision. The hook traps: a typo'd or non-executable path exits 127 and is non-blocking; a &lt;code&gt;command&lt;/code&gt;/&lt;code&gt;http&lt;/code&gt;/&lt;code&gt;mcp_tool&lt;/code&gt; hook that times out doesn't block (an Agent SDK callback hook does); only exit 2 blocks — exit 1 doesn't, and exit 2 blocks even if the JSON says allow; &lt;code&gt;Edit.*&lt;/code&gt; also matches &lt;code&gt;NotebookEdit&lt;/code&gt;; &lt;code&gt;mcp__memory&lt;/code&gt; matches nothing (use &lt;code&gt;mcp__memory__.*&lt;/code&gt;); the &lt;code&gt;if&lt;/code&gt; field holds one rule, no &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;/&lt;code&gt;||&lt;/code&gt;; &lt;code&gt;disableAllHooks&lt;/code&gt; can't disable managed hooks unless set at the managed level. Default hook timeouts (v2.1.283): 600s command/http/mcp_tool, 30s prompt, 60s agent. Mid-session CLAUDE.md edits don't apply until &lt;code&gt;/clear&lt;/code&gt;, &lt;code&gt;/compact&lt;/code&gt;, or restart; keep it under 200 lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection:&lt;/strong&gt; &lt;code&gt;claude --debug&lt;/code&gt; and watch for &lt;code&gt;Failed with non-blocking status code&lt;/code&gt;; fire &lt;code&gt;Edit&lt;/code&gt; and &lt;code&gt;NotebookEdit&lt;/code&gt; at an &lt;code&gt;Edit.*&lt;/code&gt; hook. &lt;strong&gt;Fix:&lt;/strong&gt; hard rules go in &lt;code&gt;permissions.deny&lt;/code&gt;/&lt;code&gt;ask&lt;/code&gt;; hooks exit 2 explicitly, anchor matchers (&lt;code&gt;^Edit$&lt;/code&gt;), use &lt;code&gt;mcp__server__.*&lt;/code&gt;, and verify the script path is executable. &lt;em&gt;(Official docs — single publisher.)&lt;/em&gt; (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;permissions&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;memory&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent-default four
&lt;/h2&gt;

&lt;h3&gt;
  
  
  4. The MCP timeout nobody sets defaults to ~28 hours
&lt;/h3&gt;

&lt;p&gt;Leave &lt;code&gt;MCP_TOOL_TIMEOUT&lt;/code&gt; unset and you inherit a ~28-hour default (as of v2.1.283). A per-server timeout below 1000 is ignored and falls through to &lt;code&gt;MCP_TOOL_TIMEOUT&lt;/code&gt;. HTTP/SSE get a per-request timer of max(60s, server tool timeout, &lt;code&gt;MCP_TIMEOUT&lt;/code&gt;); stdio and WebSocket have &lt;strong&gt;no per-request timer&lt;/strong&gt;, so a hung call can run the full ~28 hours (&lt;a href="https://github.com/anthropics/claude-code/issues/69487" rel="noopener noreferrer"&gt;GH#69487&lt;/a&gt;). &lt;strong&gt;Detection:&lt;/strong&gt; &lt;code&gt;echo $MCP_TOOL_TIMEOUT&lt;/code&gt; — empty means exposed. &lt;strong&gt;Fix:&lt;/strong&gt; set &lt;code&gt;MCP_TOOL_TIMEOUT&lt;/code&gt; explicitly in milliseconds (&amp;gt;= 1000), especially for stdio/WebSocket servers.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Auto mode is now the default everywhere — with trip wires you can't tune
&lt;/h3&gt;

&lt;p&gt;As of v2.1.283, auto mode is the default starting permission mode for interactive terminal and VS Code sessions on every plan and provider unless &lt;code&gt;permissions.defaultMode&lt;/code&gt; says otherwise. Its breaker is fixed: after 3 consecutive or 20 total blocks per session, auto mode pauses and goes back to prompting you (as of v2.1.283). The classifier runs server-side by default, staged from v2.1.271 to v2.1.280. &lt;strong&gt;Detection:&lt;/strong&gt; &lt;code&gt;/status&lt;/code&gt;. &lt;strong&gt;Fix:&lt;/strong&gt; &lt;code&gt;{ "permissions": { "defaultMode": "default" } }&lt;/code&gt;; separately, &lt;code&gt;export CLAUDE_CODE_AUTO_MODE_SERVER=0&lt;/code&gt; controls where the classifier runs (off the server-side path), not whether auto mode is on. (&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;permission modes&lt;/a&gt;)&lt;/p&gt;

&lt;h3&gt;
  
  
  8. The &lt;code&gt;rm -rf&lt;/code&gt; countdown resolves to DENY — and there are two different rm guards
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;"If the countdown runs out before you answer, Claude Code denies the command."&lt;/em&gt; The 2-minute countdown (as of v2.1.283) appears only in auto and bypassPermissions modes; after 3 unanswered countdowns, further critical-path removals are denied immediately. &lt;code&gt;rm -rf "$(pwd)"&lt;/code&gt; trips a separate substitution check whose opt-out, &lt;code&gt;CLAUDE_CODE_DISABLE_SUBSTITUTION_RM_PROMPT=1&lt;/code&gt;, is not &lt;code&gt;CLAUDE_CODE_DISABLE_DANGEROUS_RM_TIMEOUT=1&lt;/code&gt; (which only removes the countdown — deny-on-timeout becomes wait-forever). &lt;strong&gt;Fix:&lt;/strong&gt; leave both guards on and design unattended pipelines to expect a deny after 2 minutes. Verify only in a disposable container/VM.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Subagents run on a session-start CLAUDE.md snapshot — and &lt;code&gt;disallowedTools&lt;/code&gt; amputates whole tools
&lt;/h3&gt;

&lt;p&gt;Non-fork subagents get no conversation history and no auto memory, and their CLAUDE.md/memory is the copy the parent loaded at session start (&lt;a href="https://github.com/anthropics/claude-code/issues/88886" rel="noopener noreferrer"&gt;GH#88886&lt;/a&gt;) — mid-session edits are invisible to them. &lt;code&gt;disallowedTools: [Bash(git push *)]&lt;/code&gt; removes the whole Bash tool. When the parent is in &lt;code&gt;bypassPermissions&lt;/code&gt;/&lt;code&gt;acceptEdits&lt;/code&gt;/&lt;code&gt;auto&lt;/code&gt;, the subagent's own &lt;code&gt;permissionMode&lt;/code&gt; is ignored. &lt;strong&gt;Fix:&lt;/strong&gt; restate must-follow rules in the delegation prompt; &lt;code&gt;isolation: worktree&lt;/code&gt;; block specific commands with a &lt;code&gt;permissions.deny&lt;/code&gt; Bash rule, not &lt;code&gt;disallowedTools(specifier)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost-and-context four
&lt;/h2&gt;

&lt;h3&gt;
  
  
  9. Your prompt cache TTL quietly drops from 1 hour to 5 minutes
&lt;/h3&gt;

&lt;p&gt;As of v2.1.283 / Sep 2026, the main conversation gets a 1-hour TTL only on a subscription within plan usage; it drops to 5 minutes on usage credits and is 5 minutes by default on an API key or cloud provider. Subagents, workflows, compaction, and teammates get 5 minutes unless pinned. A &lt;code&gt;/model&lt;/code&gt; or effort switch forces a full recompute on most models. &lt;a href="https://github.com/anthropics/claude-code/issues/46829" rel="noopener noreferrer"&gt;GH#46829&lt;/a&gt; reports a 20–32% cost rise — &lt;em&gt;community-reported, directional only, not confirmed by Anthropic.&lt;/em&gt; &lt;strong&gt;Detection:&lt;/strong&gt; &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; vs &lt;code&gt;cache_read_input_tokens&lt;/code&gt; per turn; &lt;code&gt;/usage&lt;/code&gt; "Prompt cache (main)" line (v2.1.260+). &lt;strong&gt;Fix:&lt;/strong&gt; &lt;code&gt;CLAUDE_CODE_PROMPT_CACHE_TTL=1h&lt;/code&gt; and &lt;code&gt;CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=1h&lt;/code&gt; (v2.1.242+; &lt;code&gt;5m&lt;/code&gt; or &lt;code&gt;1h&lt;/code&gt;). (&lt;a href="https://code.claude.com/docs/en/prompt-caching" rel="noopener noreferrer"&gt;prompt caching&lt;/a&gt;)&lt;/p&gt;

&lt;h3&gt;
  
  
  7. &lt;code&gt;--max-budget-usd&lt;/code&gt; doesn't count what you think across resumes
&lt;/h3&gt;

&lt;p&gt;Print-mode only; subagent spend counts (v2.1.217+), but totals restored via &lt;code&gt;--continue&lt;/code&gt;/&lt;code&gt;--resume&lt;/code&gt; do NOT count toward the cap on the resumed run. &lt;code&gt;--max-turns&lt;/code&gt; is print-mode only, has no default, and a queued stream-json message at turn-end starts a fresh turn with its own limit. &lt;strong&gt;Fix:&lt;/strong&gt; track cumulative spend externally; read the stop message — a model switch doesn't help a session limit, and only an admin can raise a spend limit. &lt;em&gt;(Official docs — single publisher.)&lt;/em&gt; (&lt;a href="https://code.claude.com/docs/en/cli-reference" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;costs&lt;/a&gt;)&lt;/p&gt;

&lt;h3&gt;
  
  
  10. Four config knobs that don't do what their name implies
&lt;/h3&gt;

&lt;p&gt;As of v2.1.283: &lt;code&gt;CLAUDE_AUTOCOMPACT_PCT_OVERRIDE&lt;/code&gt; can only lower the trigger; &lt;code&gt;CLAUDE_CODE_AUTO_COMPACT_WINDOW&lt;/code&gt; takes only a plain token count (no &lt;code&gt;k&lt;/code&gt;/&lt;code&gt;M&lt;/code&gt;) and, if set, overrides &lt;code&gt;/autocompact 500k&lt;/code&gt; and &lt;code&gt;--autocompact 1M&lt;/code&gt;; &lt;code&gt;max&lt;/code&gt; effort persists only via &lt;code&gt;CLAUDE_CODE_EFFORT_LEVEL=max&lt;/code&gt; (rejected in &lt;code&gt;modelSettings&lt;/code&gt;/&lt;code&gt;effortLevel&lt;/code&gt;); &lt;code&gt;bashOutputMaxChars&lt;/code&gt; overrides &lt;code&gt;BASH_MAX_OUTPUT_LENGTH&lt;/code&gt; (default 30000, max 150000). &lt;strong&gt;Detection:&lt;/strong&gt; &lt;code&gt;/autocompact&lt;/code&gt; with no args. &lt;em&gt;(Official docs — single publisher.)&lt;/em&gt; (&lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;model config&lt;/a&gt;)&lt;/p&gt;

&lt;h3&gt;
  
  
  11. Your MCP output got truncated and saved to disk — and the chat barely tells you
&lt;/h3&gt;

&lt;p&gt;MCP output warns at 10,000 tokens and caps at 25,000 by default (as of v2.1.283). &lt;code&gt;MAX_MCP_OUTPUT_TOKENS&lt;/code&gt; only affects tools without their own &lt;code&gt;maxResultSizeChars&lt;/code&gt; (capped at 500,000 chars). Oversized non-image results go to &lt;code&gt;~/.claude/projects/.../tool-results/&lt;/code&gt; with a message naming the path. &lt;a href="https://github.com/anthropics/claude-code/issues/97622" rel="noopener noreferrer"&gt;GH#97622&lt;/a&gt; reports an extra ~50,000-char persist threshold the env var doesn't lift — &lt;em&gt;community-reported, unverified by Anthropic.&lt;/em&gt; &lt;strong&gt;Fix:&lt;/strong&gt; check whether a tool declares &lt;code&gt;maxResultSizeChars&lt;/code&gt; before relying on the env var; run &lt;code&gt;/context&lt;/code&gt; after adding any MCP server. (&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to set these (as of v2.1.283 / Sep 2026)
&lt;/h2&gt;

&lt;p&gt;From the official docs: &lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;settings&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/settings-reference" rel="noopener noreferrer"&gt;settings reference&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;env vars&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;MCP&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;permissions&lt;/a&gt;, &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;headless&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Settings files&lt;/strong&gt; (highest precedence first):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Applies to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Managed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;managed-settings.json&lt;/code&gt;, MDM, or the claude.ai console&lt;/td&gt;
&lt;td&gt;Everyone your org deploys it to; you can't override it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Command line&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude --settings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You, this session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project local&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.claude/settings.local.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You, this project only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared project&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.claude/settings.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Everyone in the project (checked into source control)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.claude/settings.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You, every project&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All of these keys can be set in &lt;strong&gt;any&lt;/strong&gt; of those files: &lt;code&gt;permissions.allow&lt;/code&gt; / &lt;code&gt;ask&lt;/code&gt; / &lt;code&gt;deny&lt;/code&gt; / &lt;code&gt;defaultMode&lt;/code&gt;, &lt;code&gt;hooks&lt;/code&gt;, &lt;code&gt;disableAllHooks&lt;/code&gt;, &lt;code&gt;promptCacheTtl&lt;/code&gt;, &lt;code&gt;subagentPromptCacheTtl&lt;/code&gt;, &lt;code&gt;bashOutputMaxChars&lt;/code&gt;, &lt;code&gt;autoCompactWindow&lt;/code&gt;, &lt;code&gt;effortLevel&lt;/code&gt;, &lt;code&gt;disabledMcpjsonServers&lt;/code&gt;, &lt;code&gt;sandbox.autoAllowBashIfSandboxed&lt;/code&gt;, &lt;code&gt;showThinkingSummaries&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt;. Exception: &lt;code&gt;permissions.defaultMode&lt;/code&gt; values &lt;code&gt;auto&lt;/code&gt; and &lt;code&gt;bypassPermissions&lt;/code&gt; don't take effect from project or local settings — set them in user or managed settings.&lt;/p&gt;

&lt;p&gt;Team-wide guardrails (deny rules, hooks, &lt;code&gt;disabledMcpjsonServers&lt;/code&gt;) belong in the checked-in &lt;code&gt;.claude/settings.json&lt;/code&gt;; personal preferences belong in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ~/.claude/settings.json  (or .claude/settings.json for the whole team)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push *)"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"defaultMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sandbox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"autoAllowBashIfSandboxed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"promptCacheTtl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1h"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subagentPromptCacheTtl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1h"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"MCP_TOOL_TIMEOUT"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;milliseconds&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Environment variables&lt;/strong&gt; (&lt;code&gt;MCP_TOOL_TIMEOUT&lt;/code&gt;, &lt;code&gt;MAX_MCP_OUTPUT_TOKENS&lt;/code&gt;, &lt;code&gt;CLAUDE_CODE_PROMPT_CACHE_TTL&lt;/code&gt;, &lt;code&gt;CLAUDE_CODE_EFFORT_LEVEL&lt;/code&gt;, &lt;code&gt;CLAUDE_CODE_AUTO_MODE_SERVER&lt;/code&gt;, …) can be set two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in your shell before launching &lt;code&gt;claude&lt;/code&gt; (&lt;code&gt;export VAR=value&lt;/code&gt;; add it to &lt;code&gt;~/.zshrc&lt;/code&gt; / &lt;code&gt;~/.bashrc&lt;/code&gt; to persist), or&lt;/li&gt;
&lt;li&gt;under the &lt;code&gt;"env"&lt;/code&gt; key of any settings file above. If both are set, the settings-file value wins.&lt;/li&gt;
&lt;li&gt;Exception: &lt;code&gt;CLAUDE_CODE_DISABLE_DANGEROUS_RM_TIMEOUT&lt;/code&gt; is read only from the environment you launch &lt;code&gt;claude&lt;/code&gt; from — it is ignored in a settings &lt;code&gt;env&lt;/code&gt; block.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;MCP servers:&lt;/strong&gt; project-scoped servers live in &lt;code&gt;.mcp.json&lt;/code&gt; at the project root; user- and local-scoped servers live in &lt;code&gt;~/.claude.json&lt;/code&gt;. A per-server &lt;code&gt;"timeout"&lt;/code&gt; field (milliseconds, ≥1000) in a &lt;code&gt;.mcp.json&lt;/code&gt; entry overrides &lt;code&gt;MCP_TOOL_TIMEOUT&lt;/code&gt; for that server only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to write &lt;code&gt;permissions.allow&lt;/code&gt; / &lt;code&gt;ask&lt;/code&gt; / &lt;code&gt;deny&lt;/code&gt; rules&lt;/strong&gt; (code.claude.com/docs/en/permissions, fetched 2026-09-29):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;allow&lt;/strong&gt; — Claude Code runs the tool without asking. &lt;strong&gt;ask&lt;/strong&gt; — it prompts you for confirmation every time. &lt;strong&gt;deny&lt;/strong&gt; — it's blocked. Rules are evaluated deny → ask → allow; the first match wins, and specificity doesn't change the order.&lt;/li&gt;
&lt;li&gt;Syntax is &lt;code&gt;Tool(specifier)&lt;/code&gt;: &lt;code&gt;Bash(npm run build)&lt;/code&gt; (exact command), &lt;code&gt;Bash(npm run *)&lt;/code&gt; (prefix — the space before &lt;code&gt;*&lt;/code&gt; matters; &lt;code&gt;Bash(ls:*)&lt;/code&gt; = &lt;code&gt;Bash(ls *)&lt;/code&gt;), &lt;code&gt;Read(./.env)&lt;/code&gt;, &lt;code&gt;Read(./secrets/**)&lt;/code&gt;, &lt;code&gt;WebFetch(domain:example.com)&lt;/code&gt;, and in deny/ask only, tool-name globs like &lt;code&gt;mcp__*&lt;/code&gt; (every MCP tool).&lt;/li&gt;
&lt;li&gt;Compound commands are split on &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;||&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt; and newlines; a deny or ask rule applies if &lt;strong&gt;any&lt;/strong&gt; subcommand matches.&lt;/li&gt;
&lt;li&gt;Limit: a Bash rule matches the command text Claude writes, &lt;strong&gt;not the program&lt;/strong&gt; — &lt;code&gt;Bash(rm *)&lt;/code&gt; stops &lt;code&gt;rm -rf build/&lt;/code&gt; but not &lt;code&gt;/bin/rm -rf build/&lt;/code&gt; or &lt;code&gt;bash -c 'rm -rf build/'&lt;/code&gt;. The docs say it "isn't a security boundary around the program"; pair it with the sandbox.&lt;/li&gt;
&lt;li&gt;View and edit the active rules with &lt;code&gt;/permissions&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;.claude/settings.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(team)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;or&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;~/.claude/settings.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(you)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Bash(npm run *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git commit *)"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ask"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Bash(git clean *)"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Read(./.env)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Read(./secrets/**)"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What "headless" means:&lt;/strong&gt; running Claude Code with no person at the terminal — &lt;code&gt;claude -p "&amp;lt;prompt&amp;gt;"&lt;/code&gt; (the &lt;code&gt;-p&lt;/code&gt; / &lt;code&gt;--print&lt;/code&gt; flag runs it non-interactively) from a script, cron job or CI pipeline such as GitHub Actions, or the Agent SDK from Python/TypeScript. The docs: a &lt;code&gt;-p&lt;/code&gt; session "shows no workspace trust dialog and no per-server approval prompt", and it still runs the hooks in the project's &lt;code&gt;.claude/settings.json&lt;/code&gt; and connects the servers in its &lt;code&gt;.mcp.json&lt;/code&gt;, even in a folder you've never trusted. &lt;code&gt;-p&lt;/code&gt; starts in Manual permission mode on every plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subagent definitions&lt;/strong&gt; live in &lt;code&gt;.claude/agents/&lt;/code&gt; (fields such as &lt;code&gt;isolation: worktree&lt;/code&gt;, &lt;code&gt;memory&lt;/code&gt;, &lt;code&gt;permissionMode&lt;/code&gt;, &lt;code&gt;disallowedTools&lt;/code&gt; go in the agent file's frontmatter).&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath all eleven
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Nothing errors.&lt;/strong&gt; The ask rule resolves to allow. The headless run loads &lt;code&gt;.mcp.json&lt;/code&gt; unprompted. The typo'd hook exits 127 and the action proceeds. The stdio MCP call keeps waiting. The resumed budget covers only new spend. The cache TTL drops to 5 minutes and the bill doesn't say why. The thing you configured isn't the thing the client enforces — so the discipline that pays off is &lt;strong&gt;verifying the layer that actually enforces&lt;/strong&gt;: canary your &lt;code&gt;ask&lt;/code&gt;/&lt;code&gt;deny&lt;/code&gt; rules, pin &lt;code&gt;permissions.defaultMode&lt;/code&gt;, set &lt;code&gt;MCP_TOOL_TIMEOUT&lt;/code&gt; and cache TTLs explicitly, and watch &lt;code&gt;/usage&lt;/code&gt;, &lt;code&gt;/context&lt;/code&gt;, and &lt;code&gt;claude mcp list&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Pick the three that map to your setup and add a check this week. The one that catches a real incident pays for the afternoon.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's bitten you that isn't here? I'm collecting Claude Code gotchas for agentic workflows — reply and I'll add the good ones with credit.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>agents</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Instant at 10 Million Rows, Dead at a Billion: A Decision Framework for Billion-Row SQL</title>
      <dc:creator>sendtoshailesh</dc:creator>
      <pubDate>Mon, 31 Aug 2026 08:18:11 +0000</pubDate>
      <link>https://dev.to/sendtoshailesh/instant-at-10-million-rows-dead-at-a-billion-a-decision-framework-for-billion-row-sql-4ig1</link>
      <guid>https://dev.to/sendtoshailesh/instant-at-10-million-rows-dead-at-a-billion-a-decision-framework-for-billion-row-sql-4ig1</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjor6o3iqcpsjlo6kjt8l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjor6o3iqcpsjlo6kjt8l.png" alt="Abstract brand-toned data pathway backdrop for the billion-row SQL decision framework." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A query that returns in 40 milliseconds on ten million rows can time out completely on a billion. Same SQL, same schema, same database. I've watched a lot of teams hit that wall and go hunting for the one fix: a cleverer index, a bigger instance, whatever database was trending that month. There isn't one fix. There are two questions that decide almost everything. What engine class is this workload on — row-store, columnar, or distributed? And does the query's working set fit in memory, or does it spill to disk? Answer those honestly and most "billion-row problems" quietly resolve. Answer them wrong and no amount of tuning bails you out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that was instant at 10 million rows and timed out at a billion
&lt;/h2&gt;

&lt;p&gt;Here's the shape of it. It's a composite; I've seen it often enough that the specifics blur.&lt;/p&gt;

&lt;p&gt;A team builds a dashboard on their main OLTP database. One query carries it: count distinct users, grouped by day, over a date range. Against a 10-million-row staging copy it comes back instantly, everyone signs off, ship it. Months later production crosses a billion rows — roughly the &lt;a href="https://tech.marksblogg.com/benchmarks.html" rel="noopener noreferrer"&gt;1.1-billion-row scale in Mark Litwintschik's cross-engine benchmarks&lt;/a&gt; — and the identical query simply stops returning. On-call adds an index; nothing. A composite index; nothing. A bigger instance; somehow slightly worse. "Throw it at Spark," one person says. "Just use ClickHouse," says another. Nobody can explain why either would help. That's the real problem: everyone is arguing tactics with no framework for the decision.&lt;/p&gt;

&lt;p&gt;The query didn't regress. It's doing exactly what it always did. What changed is that the workload crossed two lines at once — it outgrew the engine class that suited it at 10M, and its sorts and hashes stopped fitting in memory. Name those two lines and the mystery turns into a decision you can defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-axis framework
&lt;/h2&gt;

&lt;p&gt;When a query dies at scale, the reflex is to index harder. Sometimes that's right. At a billion rows it's usually the wrong axis. Two levers actually decide whether a query survives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Axis 1 — Engine class.&lt;/strong&gt; Every database is built around one dominant access pattern, and that choice shapes everything downstream. A &lt;strong&gt;row-store OLTP&lt;/strong&gt; engine (PostgreSQL, MySQL) keeps whole rows together and excels at "fetch or update this one record" and narrow ranges. A &lt;strong&gt;columnar/OLAP&lt;/strong&gt; engine (ClickHouse, DuckDB, BigQuery) stores each column separately, so scanning and aggregating hundreds of millions of rows is the design point rather than the emergency. A &lt;strong&gt;distributed&lt;/strong&gt; engine (Citus, Spark, Cassandra) spreads data across nodes for horizontal scale, trading query flexibility to get it. These are peers, not rungs on a ladder; none is "more advanced." Run a workload on the wrong one and you get a query that flies at 10M and dies at 1B.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Axis 2 — Memory budget.&lt;/strong&gt; Independent of engine class, every non-trivial query builds intermediate structures: sort buffers, hash tables for &lt;code&gt;GROUP BY&lt;/code&gt; and &lt;code&gt;DISTINCT&lt;/code&gt;, join tables. Fit those in RAM and the operation runs at memory speed. Overflow the memory the engine is allowed, and it spills to disk and falls off a cliff. PostgreSQL ships &lt;code&gt;shared_buffers&lt;/code&gt; at &lt;strong&gt;128MB&lt;/strong&gt; and &lt;code&gt;work_mem&lt;/code&gt; at just &lt;strong&gt;4MB&lt;/strong&gt;; the docs are explicit that a sort or hash exceeding &lt;code&gt;work_mem&lt;/code&gt; starts "writing to temporary disk files" (&lt;a href="https://www.postgresql.org/docs/current/runtime-config-resource.html" rel="noopener noreferrer"&gt;PostgreSQL 18 — Resource Consumption&lt;/a&gt;, as of 2026). Four megabytes is nothing at a billion rows. This is the axis most write-ups skip, and it's why the same query is fast one day and glacial the next. The SQL didn't change; the data just got big enough to spill.&lt;/p&gt;

&lt;p&gt;The axes are orthogonal. You can pick the right engine class and still hit the memory cliff, or keep memory in check and still be on an engine that's wrong for the query's shape. So the move is: work out which quadrant you're actually in, then act on the axis that matches — change engine class, or change the memory budget — instead of adding another index to the axis that was never the problem.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc211y8u0w4hwprji3b9t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc211y8u0w4hwprji3b9t.png" alt="Two-axis decision framework: engine class (row-store, columnar, distributed) by memory budget (fits RAM vs spills to disk), with each quadrant's failure mode and the right move." width="799" height="552"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Engine class 1: Row-store OLTP — great at point lookups, brutal on full-table aggregates
&lt;/h2&gt;

&lt;p&gt;Row-stores are the workhorse for good reason. Ask for "order #48213 and its line items" or "the last 50 events for this user" and a row-store with the right index is hard to beat: it seeks to a handful of rows and returns. At 10M rows, the dashboard query was riding on that.&lt;/p&gt;

&lt;p&gt;Aggregates over the whole table are a different story, and the reason is baked into how MVCC row-stores stay correct under concurrency. Because different transactions can see different versions of a row, the engine keeps no cached count of how many rows a table has. An exact &lt;code&gt;count(*)&lt;/code&gt; therefore has to visit rows and check visibility — a sequential scan that grows linearly with the table.&lt;/p&gt;

&lt;p&gt;The numbers are blunt. In Joe Nelson's &lt;a href="https://www.citusdata.com/blog/2016/10/12/count-performance/" rel="noopener noreferrer"&gt;Citus benchmark on counting&lt;/a&gt; (PostgreSQL 9.5.4, 2016 — treat the milliseconds as illustrative of the mechanism, which is unchanged today, not as current figures), an exact &lt;code&gt;count(*)&lt;/code&gt; ran &lt;strong&gt;85ms at 1M rows, 161ms at 2M, and 343ms at 4M&lt;/strong&gt;: a straight line, with the scan about &lt;strong&gt;88%&lt;/strong&gt; of the cost. Extend that to a billion rows and a single count is tens of seconds before any filtering. &lt;code&gt;count(1)&lt;/code&gt; doesn't rescue you; it measured slightly &lt;em&gt;slower&lt;/em&gt; (99ms vs 85ms at 1M), because Postgres special-cases the argument-free &lt;code&gt;count(*)&lt;/code&gt; while &lt;code&gt;count(1)&lt;/code&gt; re-checks the argument on every row. And this isn't Postgres being difficult — MySQL's InnoDB shares the MVCC property and also has no O(1) table count.&lt;/p&gt;

&lt;p&gt;When you don't need an exact number, stop asking for one. Every row-store keeps a maintained estimate for its planner, and you can read it directly. In PostgreSQL that's &lt;code&gt;pg_class.reltuples&lt;/code&gt; (the &lt;a href="https://wiki.postgresql.org/wiki/Count_estimate" rel="noopener noreferrer"&gt;Count-estimate pattern&lt;/a&gt;: &lt;code&gt;SELECT reltuples::bigint FROM pg_class WHERE oid = 'schema.table'::regclass&lt;/code&gt;). In the same benchmark the estimate returned in about &lt;strong&gt;0.3ms against 85ms&lt;/strong&gt; for the exact count at 1M rows — roughly &lt;strong&gt;280× faster&lt;/strong&gt;, and effectively constant time at any size. The catch is accuracy: it's an estimate and can't apply a &lt;code&gt;WHERE&lt;/code&gt; or &lt;code&gt;DISTINCT&lt;/code&gt;. For "roughly how many rows" on a dashboard, that's the difference between a billion-row scan and reading one catalog row.&lt;/p&gt;

&lt;p&gt;None of this means "Postgres is slow." It means a full-table aggregate is off-design for a row-store. Approximate counts, materialized rollups, and summary tables all soften it. But if scans and aggregates are your &lt;em&gt;dominant&lt;/em&gt; pattern, you're fighting the engine class — and that's your first crossover signal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzq34yubj7dm8qdlja9zb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzq34yubj7dm8qdlja9zb.png" alt="Exact count(*) rises linearly with table size while an approximate reltuples read stays flat and constant-time — roughly a 280x gap." width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Exact `count(&lt;/em&gt;)&lt;code&gt; vs the &lt;/code&gt;reltuples` estimate. Source: Citus / Joe Nelson, PostgreSQL 9.5.4, 2016 — illustrative of the mechanism, not current figures.*&lt;/p&gt;

&lt;h2&gt;
  
  
  Engine class 2: Columnar / OLAP — where scans and aggregates are the design point
&lt;/h2&gt;

&lt;p&gt;If aggregates are killing your row-store, what's built for them, and how much faster is it really? This is where the framework gets counter-intuitive.&lt;/p&gt;

&lt;p&gt;Columnar engines store each column contiguously instead of packing whole rows. A scan-and-aggregate query that touches 3 of 51 columns reads only those 3 off disk; the other 48 are never opened. And because a column holds one type with heavy repetition, it compresses hard, so there's less to read to begin with. Column pruning plus compression is why "scan a billion rows and aggregate" is routine here instead of an incident.&lt;/p&gt;

&lt;p&gt;The headline number: Litwintschik's &lt;a href="https://tech.marksblogg.com/benchmarks.html" rel="noopener noreferrer"&gt;1.1-billion-row taxi benchmark&lt;/a&gt; (1.1B rows, 51 columns, ~500GB uncompressed; page updated March 2024) runs one analytical query across dozens of engines. On Query 1, a single desktop ran &lt;strong&gt;ClickHouse in 0.088s&lt;/strong&gt; and &lt;strong&gt;DuckDB in 0.498s&lt;/strong&gt;. The same query took &lt;strong&gt;2.36s on a 21-node Spark cluster&lt;/strong&gt;, &lt;strong&gt;3.54s on 21-node Presto&lt;/strong&gt;, and &lt;strong&gt;1–3s on BigQuery&lt;/strong&gt;. Postgres with the &lt;code&gt;cstore_fdw&lt;/code&gt; columnar extension took &lt;strong&gt;152–368s&lt;/strong&gt; across the four queries, and plain SQLite's format took &lt;strong&gt;31,193s on Query 1 — about 8.7 hours&lt;/strong&gt;, included to show what the wrong engine class actually costs.&lt;/p&gt;

&lt;p&gt;A single desktop beat a 21-node cluster on a 1.1-billion-row scan by more than an order of magnitude. That one result is the whole framework in miniature. It isn't that ClickHouse or DuckDB is "the best database" — it's that for a scan-aggregate workload a columnar engine is in the right quadrant while the cluster pays coordination overhead it doesn't need at this size. (Caveats: one benchmark, one hardware generation, recent desktop CPUs for the fastest single-node runs. Trust the shape of the gap, not the exact milliseconds.)&lt;/p&gt;

&lt;p&gt;Compression is the other half, and it's an economics lever as much as a speed one. ClickBench — the ClickHouse team's &lt;a href="https://github.com/ClickHouse/ClickBench" rel="noopener noreferrer"&gt;scan/aggregate benchmark&lt;/a&gt; across 60+ systems — publishes a live "Data Size" column so you can compare on-disk footprint on the same ~100M-row dataset. Columnar layouts routinely pack this kind of wide, repetitive data down by a large multiple versus a row-store, though the exact ratio moves with engine, codec, and data, so read the live figure for the systems you're weighing rather than trusting a headline number. (ClickBench is ClickHouse-run, so treat it as a scan-workload reference, not a neutral verdict.)&lt;/p&gt;

&lt;p&gt;The honest counter-point: the same columnar engine that crushes the scan is the wrong tool for high-QPS single-row writes and point lookups. Insert a row and you touch every column store; update a field and compression works against you. The wide-column and key-value stores built for that job — Cassandra, ScyllaDB — sit at the opposite pole: excellent at point reads and writes on a known partition key, and structurally unfit for ad-hoc full-table aggregates. Cassandra's &lt;a href="https://cassandra.apache.org/doc/latest/cassandra/developing/cql/dml.html" rel="noopener noreferrer"&gt;CQL docs&lt;/a&gt; say it plainly: CQL "only allows select queries that don't involve a full scan of all partitions," and forcing one needs &lt;code&gt;ALLOW FILTERING&lt;/code&gt;, whose performance the docs call "unpredictable." A store modeled query-first around the partition key isn't a scan-aggregate engine, which is exactly why ClickBench lists Cassandra and ScyllaDB among the systems it couldn't benchmark on that suite. Right engine, wrong job, in both directions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fto3pff62i46n54pcrjrq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fto3pff62i46n54pcrjrq.png" alt="Litwintschik 1.1-billion-row Query 1: a single desktop running ClickHouse or DuckDB beats a 21-node Spark/Presto cluster; SQLite is the wrong-tool extreme (log scale)." width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same query, same 1.1B-row dataset, across engine classes. Source: Mark Litwintschik, 1.1 Billion Taxi Rides benchmarks, page updated March 2024.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Engine class 3: Distributed — horizontal scale, and the fan-out ceiling
&lt;/h2&gt;

&lt;p&gt;When one node genuinely can't hold the data or serve the writes, distributed engines spread the work across many. The mental model most people carry — more nodes, more speed — holds right up until it doesn't, and knowing where that line sits is the difference between a defensible cluster and a single box with a network bill attached.&lt;/p&gt;

&lt;p&gt;The line: does the query push down to the shards, or force a reshuffle? Distribute by &lt;code&gt;tenant_id&lt;/code&gt; and group by &lt;code&gt;tenant_id&lt;/code&gt;, and each node computes its slice while the coordinator stitches the results together. That's pushdown, and it parallelizes beautifully. But the moment a query needs data spread across nodes — a &lt;code&gt;count(DISTINCT)&lt;/code&gt; on a non-distribution column, or a join across the distribution boundary — the engine has to reshuffle rows between workers first. That shuffle is the ceiling.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.citusdata.com/blog/2016/10/12/count-performance/" rel="noopener noreferrer"&gt;Citus benchmark&lt;/a&gt; puts numbers on it (100M rows, 8 nodes, 32 shards; PG 9.5.4, 2016 — again mechanism-illustrative). A plain &lt;code&gt;count(*)&lt;/code&gt; across the cluster ran about &lt;strong&gt;1.2s&lt;/strong&gt;. A &lt;code&gt;count(DISTINCT)&lt;/code&gt; on the distribution column, which pushes down, ran about &lt;strong&gt;3.4s&lt;/strong&gt;. On a non-distribution column it can't push down, and pulling every row back to the coordinator is, in Nelson's words, "really no better than counting on a single database instance … plus high network overhead." An HLL approximate distinct ran &lt;strong&gt;3.2–3.8s&lt;/strong&gt; — the row-store's approximation trick again, now buying a way around the shuffle. This generalizes well past Citus: in Spark the expensive stage is almost always the shuffle, and distributed query design is mostly the craft of keeping the hot queries pushed down.&lt;/p&gt;

&lt;p&gt;So distributed isn't a "scale" button. It's a trade — horizontal capacity for query flexibility and operational complexity. Align your access pattern with the distribution key and it's transformative. Keep needing cross-node data and you've bought nodes &lt;em&gt;and&lt;/em&gt; network latency to arrive back at roughly single-box performance. That mismatch is its own crossover signal, and the one most likely to be discovered the hard way in production.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Funrivaled-starlight-d655be.netlify.app%2Fblog%2Fvisuals%2Fv04-fanout-pushdown.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Funrivaled-starlight-d655be.netlify.app%2Fblog%2Fvisuals%2Fv04-fanout-pushdown.png" alt="Two distributed query paths: one pushes down to shards and merges at the coordinator; the other forces a cross-node shuffle that collapses to a single box plus network overhead." width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The memory / spill axis — the one most write-ups skip
&lt;/h2&gt;

&lt;p&gt;Now the second axis, and the reason "just add an index" so often does nothing. A query can be on exactly the right engine class and still fall off a cliff, because its sorts and hash tables outgrew the memory the engine was allowed and spilled to disk. This is engine-independent: columnar and distributed engines build the same buffers and spill the same way when they overflow their limits. Postgres is just the easiest place to watch it, because its docs name the knob.&lt;/p&gt;

&lt;h3&gt;
  
  
  The spill cliff
&lt;/h3&gt;

&lt;p&gt;At the default 4MB &lt;code&gt;work_mem&lt;/code&gt; (&lt;a href="https://www.postgresql.org/docs/current/runtime-config-resource.html" rel="noopener noreferrer"&gt;PostgreSQL docs&lt;/a&gt;), a &lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;DISTINCT&lt;/code&gt;, or &lt;code&gt;ORDER BY&lt;/code&gt; that builds something larger switches from an in-memory hash or sort to an external merge that writes temp files to disk. The &lt;a href="https://www.citusdata.com/blog/2016/10/12/count-performance/" rel="noopener noreferrer"&gt;Citus benchmark&lt;/a&gt; shows the size of the drop (PG 9.5.4, 2016; mechanism unchanged per the current docs): at default &lt;code&gt;work_mem&lt;/code&gt;, &lt;code&gt;count(DISTINCT)&lt;/code&gt; on an integer column averaged &lt;strong&gt;743ms&lt;/strong&gt;, but on a text column &lt;strong&gt;31,747ms&lt;/strong&gt; — a &lt;strong&gt;~43× gap&lt;/strong&gt; — with &lt;code&gt;Sort Method: external merge Disk&lt;/code&gt; in the plan. Give the operation enough &lt;code&gt;work_mem&lt;/code&gt; to stay in RAM and the integer case drops to about &lt;strong&gt;372ms&lt;/strong&gt; as an in-memory &lt;code&gt;HashAggregate&lt;/code&gt;. That in-RAM-versus-disk boundary is the axis. Stay on the right side and the query flies; cross it and it collapses.&lt;/p&gt;

&lt;p&gt;This is exactly why indexing didn't help the composite team. An index changes how you &lt;em&gt;find&lt;/em&gt; rows; it does nothing for a &lt;code&gt;GROUP BY&lt;/code&gt;/&lt;code&gt;DISTINCT&lt;/code&gt; whose result set is too big for &lt;code&gt;work_mem&lt;/code&gt;. The lever there is memory, or a smaller working set — not another index.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to re-architect instead of tune
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;work_mem&lt;/code&gt; is allocated per operation per query, and the docs warn total usage "could be many times" that value under load, so raising it globally just trades a spill problem for an out-of-memory one. Past a point the honest move is structural. PostgreSQL's &lt;a href="https://www.postgresql.org/docs/current/ddl-partitioning.html" rel="noopener noreferrer"&gt;partitioning guidance&lt;/a&gt; offers a clean rule: partition "when the size of the table [would] exceed the physical memory of the database server." Pruning then skips whole partitions — but it isn't free. The planner handles "up to a few thousand" partitions well only if pruning leaves few, since "each partition requires its metadata to be loaded into the local memory of each session that touches it." Thousands of poorly-pruned partitions is its own problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  One more trap: deep pagination
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;LIMIT ... OFFSET n&lt;/code&gt; gets slower the further you page. As Markus Winand explains in &lt;a href="https://use-the-index-luke.com/no-offset" rel="noopener noreferrer"&gt;"No Offset"&lt;/a&gt;, the database "fetches and drops" all &lt;code&gt;n&lt;/code&gt; preceding rows before returning the page, and results drift when rows are inserted between requests. A keyset (seek) query filters on the last-seen sort key instead and stays roughly constant-time however deep you go. Page latency that climbs with depth is a spill-adjacent signal, not a hardware one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3423wurqedo5wuhmsff.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3423wurqedo5wuhmsff.png" alt="The spill cliff: an in-RAM hash aggregate vs an external merge to disk, and the collapse back to memory speed when work_mem is raised." width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;count(DISTINCT) at default vs raised work_mem. Source: Citus / Joe Nelson, PostgreSQL 9.5.4, 2016 — illustrative of the mechanism, not current figures.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The crossover-signal checklist: change engine class, don't add another index
&lt;/h2&gt;

&lt;p&gt;This is the piece worth saving. A framework's job is to tell you &lt;em&gt;when&lt;/em&gt; you've hit the boundary and it's time to move on an axis instead of tuning further. Eight signals I watch for, each tied to the axis it moves. Two or more firing means you're past tuning and into an engine-class or memory-budget decision.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scans and aggregates dominate the workload&lt;/strong&gt; (not point lookups). → Engine class, toward &lt;strong&gt;columnar&lt;/strong&gt;. The row-store aggregate cliff; indexing won't fix an off-design pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The working set exceeds server RAM.&lt;/strong&gt; → Memory. Postgres's own partition threshold; partition, re-architect, or move to columnar/distributed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sorts and &lt;code&gt;DISTINCT&lt;/code&gt;s keep spilling even after tuning &lt;code&gt;work_mem&lt;/code&gt;.&lt;/strong&gt; → Memory. You've hit the ceiling of per-query memory; the working set is the problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination or scan latency rises with position.&lt;/strong&gt; → Memory/scan; switch to keyset/seek before switching engines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single node has hit its write or IO ceiling.&lt;/strong&gt; → Engine class, toward &lt;strong&gt;distributed&lt;/strong&gt;. The one genuine "add nodes" signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key &lt;code&gt;DISTINCT&lt;/code&gt;s or joins need data from several nodes&lt;/strong&gt; (a shuffle). → A warning on the distributed axis: without a distribution-key redesign, more nodes ≈ one box plus network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need thousands of partitions to stay fast.&lt;/strong&gt; → Memory/metadata; the per-partition cost says a columnar or distributed store fits the data better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're keeping 500GB+ on one box for analytics.&lt;/strong&gt; → Engine class plus economics; columnar compression and distributed storage costs start driving the decision.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What the list deliberately doesn't do is name a winning database. Each signal points to an axis and a direction, because the right specific engine depends on your query shapes, your team's ops maturity, and your budget. "Change engine class" is an architectural decision with a reason attached — something you can hand a skeptical peer or a budget owner instead of "the internet said use ClickHouse."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi8q2q29okgk2vw4p9mns.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi8q2q29okgk2vw4p9mns.png" alt="The eight crossover signals, each tagged with the axis it moves (engine class or memory budget) and the recommended direction." width="800" height="653"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost and economics for the decision-makers
&lt;/h2&gt;

&lt;p&gt;If you own the stack decision, latency is half the case; the half that gets budget approved is cost and defensibility. The benchmarks reframe cleanly as money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A single big box can be cheaper &lt;em&gt;and&lt;/em&gt; faster than a cluster.&lt;/strong&gt; The &lt;a href="https://tech.marksblogg.com/benchmarks.html" rel="noopener noreferrer"&gt;Litwintschik result&lt;/a&gt; — one desktop-class node beating a 21-node cluster on a 1.1B-row scan — reads louder as a cost story than a speed one: 21 nodes carry ~21× the compute plus coordination, networking, and a standing ops burden, and still lose on this workload. For "big but not planetary" analytics, the first move is often the right engine class on one well-sized box, not a cluster. Distributed earns its cost when you truly exceed single-node capacity or need the availability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compression is a storage-cost lever.&lt;/strong&gt; A columnar layout shrinks wide, repetitive data by a large multiple (measure it for your engines from &lt;a href="https://github.com/ClickHouse/ClickBench" rel="noopener noreferrer"&gt;ClickBench's "Data Size" column&lt;/a&gt; — it varies by engine and codec), which is a recurring cut in bytes stored and, in a warehouse, fewer dollars per query scanned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-query billing cuts both ways.&lt;/strong&gt; A serverless warehouse (BigQuery ran 1–3s per query here) fits spiky, ad-hoc analytics where an idle cluster would bleed money, and fits badly for a high-frequency dashboard hammering one aggregate, where a right-sized columnar box wins on total cost.&lt;/p&gt;

&lt;p&gt;Every price and benchmark here will rotate — pricing shifts, hardware moves the single-node ceiling, vendor benchmarks get re-run. Don't anchor the decision to one dated figure. The framework doesn't rotate: two axes, eight signals. If a price moves, re-run the comparison.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvonejdp7c70ilkt2sqit.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvonejdp7c70ilkt2sqit.png" alt="Cost and operations profile per engine class: relative storage cost, node count, operational burden, query flexibility, and best-fit workload." width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Build it yourself
&lt;/h2&gt;

&lt;p&gt;The framework is cheap until you feel it on your own data. Three projects, beginner to advanced. Each names a real tool (links verified reachable in 2026, but repos move — check before you lean on them).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project 1 — Count your table three ways (~30 min).&lt;/strong&gt; Feel the row-store aggregate cliff on real data. You'll need a Postgres or MySQL table with 10M+ rows and &lt;a href="https://github.com/duckdb/duckdb" rel="noopener noreferrer"&gt;DuckDB&lt;/a&gt;. Run &lt;code&gt;EXPLAIN ANALYZE SELECT count(*) FROM your_table;&lt;/code&gt; and note the time and the &lt;code&gt;Seq Scan&lt;/code&gt;; then &lt;code&gt;SELECT reltuples::bigint FROM pg_class WHERE oid = 'schema.your_table'::regclass;&lt;/code&gt;; then get a slice into columnar form — export CSV (&lt;code&gt;COPY your_table TO 'slice.csv' (FORMAT csv)&lt;/code&gt;) and load it into DuckDB, or read the table directly with &lt;a href="https://github.com/duckdb/pg_duckdb" rel="noopener noreferrer"&gt;pg_duckdb&lt;/a&gt; — and &lt;code&gt;count(*)&lt;/code&gt; there. &lt;strong&gt;Done when&lt;/strong&gt; you can state your own numbers for all three and point to the &lt;code&gt;Seq Scan&lt;/code&gt;. Stretch: add &lt;code&gt;GROUP BY day&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project 2 — Reproduce the spill cliff (~1–2 hrs).&lt;/strong&gt; Watch a query cross the RAM→disk boundary. You'll need Postgres and a 1M+ row table with an integer and a text column (the NYC-taxi or ClickBench sets work). At default &lt;code&gt;work_mem&lt;/code&gt;, run &lt;code&gt;EXPLAIN ANALYZE SELECT count(DISTINCT int_col) FROM t;&lt;/code&gt; then the same on &lt;code&gt;text_col&lt;/code&gt;, and confirm &lt;code&gt;Sort Method: external merge Disk&lt;/code&gt; on the slow one. &lt;code&gt;SET work_mem = '1GB';&lt;/code&gt;, re-run both. &lt;strong&gt;Done when&lt;/strong&gt; you've captured your own before/after ratio and seen the plan flip to in-memory &lt;code&gt;HashAggregate&lt;/code&gt;. Stretch: chart latency as you step &lt;code&gt;work_mem&lt;/code&gt; from 4MB to 1GB and find your own cliff edge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project 3 — Run a slice of the 1.1B benchmark (~half a day).&lt;/strong&gt; Reproduce the crossover on your hardware. You'll need the same big table in a row-store and a columnar engine, plus the &lt;a href="https://github.com/ClickHouse/ClickBench" rel="noopener noreferrer"&gt;ClickBench loaders&lt;/a&gt; (60+ DBMS setup scripts) and &lt;a href="https://tech.marksblogg.com/benchmarks.html" rel="noopener noreferrer"&gt;Litwintschik's posts&lt;/a&gt; for reference. Load an identical scan-aggregate table into each, time one representative query on both, and record the ratio and your hardware. &lt;strong&gt;Done when&lt;/strong&gt; you reproduce a &amp;gt;100× gap — or can explain from the plans why yours differs. Stretch: add a distributed engine and build a query that forces a shuffle to find your own fan-out ceiling.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Funrivaled-starlight-d655be.netlify.app%2Fblog%2Fvisuals%2Fv08-project-ladder.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Funrivaled-starlight-d655be.netlify.app%2Fblog%2Fvisuals%2Fv08-project-ladder.png" alt="Build-it-yourself project ladder: beginner to advanced, each rung mapped to an axis — aggregate cliff, spill cliff, cross-engine crossover." width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do Monday morning
&lt;/h2&gt;

&lt;p&gt;The whole thing, compressed. When a query dies at scale, stop asking "which index?" and ask the two questions that decide it. Engine class: is the dominant shape point-lookups (row-store), scans and aggregates (columnar), or genuinely bigger than one node (distributed)? Memory budget: does the working set fit in RAM, or is it spilling? Then walk the eight signals; two or more means the move is on an axis, not another index. There's no single answer to "how do I handle a billion rows" — the answer is a decision you can write down and defend.&lt;/p&gt;

&lt;p&gt;So go run it. Take your biggest table this week and do Project 1: &lt;code&gt;count(*)&lt;/code&gt;, then &lt;code&gt;reltuples&lt;/code&gt;, then a columnar count in &lt;a href="https://github.com/duckdb/duckdb" rel="noopener noreferrer"&gt;DuckDB&lt;/a&gt;. Watch where the plan shows a &lt;code&gt;Seq Scan&lt;/code&gt; and how far apart the three numbers land. Want the full crossover? The &lt;a href="https://github.com/ClickHouse/ClickBench" rel="noopener noreferrer"&gt;ClickBench repo&lt;/a&gt; has loaders for 60+ engines, so you can time the same scan on a row-store and a columnar engine side by side. Bring back your own numbers — the framework only lands once the cliff is yours.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>dataengineering</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
