Verified against ChatGPT · 2026-08-08
Turn a plain-English analytics question into a parameterized Postgres query that won't get you paged for a table scan
Writes a parameterized (never string-formatted) Postgres query for a specific analytics question, plus the EXPLAIN-based check to run before it goes anywhere near production data.
The prompt
Ready to copy — highlighted parts are example details you can swap.
You are a senior backend engineer writing one specific SQL query against a Postgres database for a real analytics question — not a generic "SQL tutorial" answer, and not pseudocode. QUESTION TO ANSWER For each subscription plan, what's the 30-day retention rate for customers who signed up in the last 6 months? RELEVANT TABLES AND COLUMNS subscriptions(id, customer_id, plan_id, started_at, canceled_at), customers(id, signup_channel) ESTIMATED ROW COUNTS subscriptions ~14M rows, customers ~2.1M rows HOW THIS QUERY WILL BE RUN Runs nightly via a psycopg2 cron job against a read replica, result written to a reporting table. RULES Write the query using named bind parameters (`%(param_name)s` for psycopg2-style, or `:param_name` if the execution context says it's SQLAlchemy) — never interpolate a Python f-string or `.format()` value directly into the SQL text, even for a value you think is safe, because the habit is what causes the incidents, not any single query. State explicitly which parameters are user-controlled input versus internal constants, since only the former strictly need to be parameterized but treating both the same way avoids a future edit accidentally reintroducing string formatting. If the question requires aggregating across a table you were told has millions of rows, do not default to a query that would force a sequential scan — check the given row counts against the WHERE and JOIN columns and flag any column being filtered or joined on that doesn't sound indexed based on the schema snippet, rather than silently writing a slow query and calling it done. Prefer `EXISTS` over `IN (SELECT ...)` for anti-join or existence checks against a large table, and explain in one line why, in this specific case, rather than asserting it as a rule of thumb. If the business question is ambiguous about a boundary condition (inclusive/exclusive date range, how to treat NULLs in a grouping column, whether a soft-deleted row counts), do not silently pick one interpretation — state the ambiguity and the interpretation you chose. WHAT NOT TO DO Do not wrap the answer in a generic explanation of what SQL joins are. Do not add a second, alternate version of the query "in case this isn't what you meant" — commit to one query that matches the stated question, or ask a clarifying question if the ambiguity is severe enough that guessing wrong would produce a materially different number. OUTPUT FORMAT 1. The parameterized query, formatted and commented at any non-obvious join or filter. 2. The parameter dictionary shape (name -> example value -> user-controlled or internal). 3. The `EXPLAIN (ANALYZE, BUFFERS)` command to run against it before trusting the result, plus what to look for in the output (sequential scan on a large table, a nested loop over an unindexed column) that would mean the query needs rework. 4. Any boundary-condition ambiguity you resolved and how.
Customize
Optional — swap in your own details for the highlighted parts above.
Why this works
The instruction to name bind-parameter placeholders instead of accepting any query that "looks parameterized" closes the specific gap where a model asked generically for a "safe SQL query" will happily produce a parameterized WHERE clause but then interpolate a table or column name via an f-string elsewhere in the same query, because it's pattern-matching on the visible injection risk (a value in a WHERE clause) rather than reasoning about the underlying rule (never let user-influenced text reach the SQL string directly). Supplying row counts and asking the model to reason about which filter/join columns are likely unindexed works because GPT-5.1 has no access to your actual `pg_indexes` catalog and will otherwise default to writing a correct-looking query without ever surfacing that correctness and performance are different questions — giving it the row counts turns an invisible risk into something it can actually reason about and flag, rather than silently assuming an index exists because the schema snippet happens to look like a foreign key. Forcing a stated resolution for boundary ambiguities (inclusive date ranges, NULL handling in GROUP BY) matters because these are exactly the places where a wrong silent guess produces a plausible-looking wrong number rather than an obvious error — the query still runs and returns rows, so nobody notices the interpretation was wrong until a report doesn't reconcile. Requiring the EXPLAIN command as a deliverable rather than just the query converts the output from "a SQL answer" into an actual verification step the user is expected to run before trusting the query in production, which matches how a senior engineer actually ships analytics SQL rather than pasting it straight from a chat window.
What you get back
SELECT plan_id, COUNT(*) FILTER (WHERE canceled_at IS NULL OR canceled_at > started_at + interval '30 days') * 1.0 / COUNT(*) AS retention_30d FROM subscriptions s JOIN customers c ON c.id = s.customer_id WHERE s.started_at >= %(window_start)s GROUP BY plan_id; -- window_start is user-controlled (report date range), the interval literal is internal. Run EXPLAIN (ANALYZE, BUFFERS) first and check that started_at hits an index rather than a sequential scan across 14M rows.
Verified against
ChatGPT GPT-5.1 · 2026-08-08
Changelog
- 2026-08-08 — Initial publish, verified against ChatGPT GPT-5.1.
Need this built into your business?
If a prompt isn't enough — custom software, built and maintained for you — that's Scult's day job.
EXPLORE CUSTOM SOFTWARE
