Self-Serve BI is a Denial-of-Service Attack Waiting to Happen
You connected a shiny new 'Ask our data' bot to Slack. Now your production Postgres is crippled by sequential scans from the sales team. Here's the anatomy of the failure and how to fix it.
Venkat Sakamuri
DeepSQL R&D · Ex Oracle Query Engine Team · YC & CMU

TL;DR
- Giving your company a Slack bot that raw-dog queries your database is a recipe for sequential scans, buffer cache thrashing, and PagerDuty alerts.
- Natural language to SQL is a solved-enough problem. The real danger is the unguarded execution of the generated query. The planner doesn't care about your SLOs; it just follows orders.
- True self-serve requires more than a chatbot bolted to
psql. It needs a semantic layer, cost-based execution controls, and native identity propagation to enforce RLS.
Two weeks ago, a well-meaning platform team rolled out a new 'Data Questions' Slack bot. The promise was nirvana: any employee could ask questions in plain English and get answers. No more bugging the data team. No more waiting.
At 9:15 AM on a Tuesday, the head of West Coast sales asked the bot: What were our top 10 enterprise accounts in California by usage last month?
To the business user, this is a trivial question. To our production Postgres cluster, it was a declaration of war.
Five minutes later, our primary application's p99 latency for API calls jumped from 150ms to 4,000ms. My dashboard lit up with I/O wait alarms. Our on-call DBA saw pg_stat_activity filled with dozens of queries stuck waiting for buffer content locks. The culprit was a single session, running a monstrous query, consuming all available I/O and thrashing the buffer cache.
This wasn't a malicious attack. It was a well-intentioned question translated into a catastrophic query by a tool that understands English grammar but has zero understanding of database mechanics.
The Anatomy of a "Simple" Question
The bot translated the sales leader's request into something logically correct but operationally disastrous:
SELECT
a.account_name,
SUM(e.usage_units) AS total_usage
FROM
accounts a
JOIN
users u ON a.id = u.account_id
JOIN
events e ON u.id = e.user_id
WHERE
a.state = 'CA'
AND a.tier = 'enterprise'
AND e.event_timestamp >= '2023-10-01'
AND e.event_timestamp < '2023-11-01'
GROUP BY
a.account_name
ORDER BY
total_usage DESC
LIMIT 10;
Looks reasonable, right? Wrong. The events table is our 8TB beast, partitioned by month. The users table has 150 million rows. The query planner, in its infinite wisdom, made a few fatal choices.
Because the accounts predicates (state = 'CA', tier = 'enterprise') were fairly selective, it started there. But then it had to join the result to users, and then join that to the entire October partition of events. Without a covering index on (user_id, event_timestamp) that also included usage_units, the planner opted for a hash join that required a massive sequential scan on the events_2023_10 partition.
This single query read over 900 million rows from disk. The I/O storm evicted nearly 40% of our genuinely hot data—customer session tokens, product configs, pending orders—from Postgres's shared buffer cache. Every subsequent OLTP query for our main app suddenly had to go to disk. The application ground to a halt.
The EXPLAIN ANALYZE Doesn't Lie
After we killed the offending query, we ran an EXPLAIN ANALYZE in a read replica to see the damage. The plan was ugly, but this one node tells the entire story:
-> Hash Join (cost=351000.50..7824510.15 rows=98765432) (actual time=18500.20..650123.45 rows=91034567)
Hash Cond: (e.user_id = u.id)
Buffers: shared hit=12050, read=4890145
I/O Timings: read=598123.10
-> Seq Scan on events_2023_10 e (cost=0.00..5123456.78 rows=150123456) (actual time=0.10..410987.65 rows=152345678)
Filter: ((event_timestamp >= '2023-10-01'::timestamp) AND (event_timestamp < '2023-11-01'::timestamp))
Buffers: shared hit=110, read=4780567
I/O Timings: read=399876.54
Look at those Buffers: shared read=4780567 on the Seq Scan node. That's 4.7 million 8kB blocks, or about 38 GB of data, pulled from our slow S3-backed block storage because it wasn't in memory. The I/O Timings confirm it took almost 400 seconds just to read that data off disk. The entire query took over 10 minutes to execute, monopolizing CPU and I/O the entire time.
This is the core danger. A tool that generates SQL without being able to predict the cost of that SQL is just a fancy way to let untrained users point a firehose at your database's most vulnerable tables. The DBA mantra is EXPLAIN first, execute second. These tools skip the first step entirely.
RLS and The Illusion of Governance
The performance meltdown is only half the story. The other is the governance nightmare.
We use Postgres Row-Level Security (RLS) to ensure that a sales rep for the 'DACH' region can only see accounts and users in Germany, Austria, and Switzerland. The policies are attached to their database role.
But the Slack bot doesn't work that way. It connects with a single, privileged service account that can see everything. The bot's application code is supposed to parse the user's request and add the right WHERE clauses. This is a fragile, shadow implementation of RLS, and it's guaranteed to fail.
What happens when a user asks, show me all accounts not in my region? Or there's a bug in the bot's parsing logic? The result is an instant, silent data breach. The PostgreSQL logs show a valid query from the bi_service_role, and the bot happily DMs a CSV of your entire global customer list to an intern.
True security requires identity propagation. The query must be executed by a database role reflecting the user's actual permissions, so that native RLS policies—the single source of truth—are enforced by the database itself.
What DeepSQL does about this
We built DeepSQL's Slack integration specifically to solve this governance and performance nightmare. It never executes a query blind. First, it generates the SQL and runs an internal EXPLAIN against your database, without executing. It checks the plan against cost and row-scan thresholds you define (e.g., 'reject any query scanning >10M rows on events'). If the plan is unsafe, it refuses to run, explaining why to the user. It states its schema assumptions clearly before ever touching data. Finally, it uses role mapping from your identity provider to connect to Postgres, meaning your native RLS policies are always inherited and enforced at the database layer, where they belong.
