<?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: Vivek Kumar</title>
    <description>The latest articles on DEV Community by Vivek Kumar (@vivekdraxlr).</description>
    <link>https://dev.to/vivekdraxlr</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%2F3870326%2F6ff71248-2d18-4571-9d3e-9cd0e728f3a2.png</url>
      <title>DEV Community: Vivek Kumar</title>
      <link>https://dev.to/vivekdraxlr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vivekdraxlr"/>
    <language>en</language>
    <item>
      <title>How to Choose a BI Tool for a Small Team (Without Regretting It in Six Months)</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Fri, 25 Sep 2026 13:44:50 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/how-to-choose-a-bi-tool-for-a-small-team-without-regretting-it-in-six-months-3cm5</link>
      <guid>https://dev.to/vivekdraxlr/how-to-choose-a-bi-tool-for-a-small-team-without-regretting-it-in-six-months-3cm5</guid>
      <description>&lt;p&gt;Picture a founder on a Sunday night. The company has fifteen people, revenue is climbing, and every important number — signups, churn, cash, support volume — lives in a different spreadsheet that someone updates by hand. She decides it's time for a "real" analytics tool. She opens a browser, searches "best BI tool," and finds thirty options, each claiming to be the fastest, smartest, and most affordable. Two hours later she has fourteen tabs open and no decision.&lt;/p&gt;

&lt;p&gt;If that's you, this guide is for you. &lt;strong&gt;BI&lt;/strong&gt; stands for &lt;em&gt;business intelligence&lt;/em&gt; — software that connects to your data and turns it into dashboards, reports, and answers to questions like "how did we do last month?" Picking one shouldn't require a data science degree. But it's easy to get wrong, and getting it wrong is expensive: you pay for a tool nobody uses, and you're back to spreadsheets within a quarter.&lt;/p&gt;

&lt;p&gt;Here's how to choose well, what to look for, and the traps that catch most small teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with your requirements, not the feature list
&lt;/h2&gt;

&lt;p&gt;The single most common reason companies pick the wrong tool: they start by comparing features instead of writing down what they actually need. Feature lists are designed to impress. Almost every BI tool can make a bar chart. The question is whether &lt;em&gt;this&lt;/em&gt; tool fits &lt;em&gt;your&lt;/em&gt; team, &lt;em&gt;your&lt;/em&gt; data, and &lt;em&gt;your&lt;/em&gt; budget — today and a year from now.&lt;/p&gt;

&lt;p&gt;Before you look at a single product, answer four plain questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who will actually build the reports?&lt;/strong&gt; A non-technical founder, a marketing manager, a part-time analyst?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does your data live?&lt;/strong&gt; A single Postgres database? A mix of a database, Stripe, and a CRM?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What decisions do you need to make weekly?&lt;/strong&gt; Be specific: "Is churn getting worse?" beats "we want insights."&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What can you spend — now and when you're twice the size?&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those answers are your scorecard. Everything else is a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five things that actually matter
&lt;/h2&gt;

&lt;p&gt;Once you know your requirements, judge every tool against the same five criteria. In roughly this order of importance for a small team:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;The question to ask&lt;/th&gt;
&lt;th&gt;Why it matters most for small teams&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Ease of use&lt;/td&gt;
&lt;td&gt;Can the people on my team actually build a report themselves?&lt;/td&gt;
&lt;td&gt;A tool nobody can use is shelfware, no matter how powerful.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Time to value&lt;/td&gt;
&lt;td&gt;Can I connect my data and get one useful answer within an hour?&lt;/td&gt;
&lt;td&gt;Small teams don't have weeks to spend on setup.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Data connections&lt;/td&gt;
&lt;td&gt;Does it plug into where my data already is?&lt;/td&gt;
&lt;td&gt;A BI tool is only as good as the data it can reach.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Pricing that scales&lt;/td&gt;
&lt;td&gt;What does this cost at 5 users? At 50?&lt;/td&gt;
&lt;td&gt;The cheap plan today can become the expensive one next year.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Governance &amp;amp; security&lt;/td&gt;
&lt;td&gt;Can I control who sees what?&lt;/td&gt;
&lt;td&gt;Not everyone should see payroll or customer PII.&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Let's unpack the two that trip people up most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ease of use is the whole game.&lt;/strong&gt; Research consistently finds that most BI projects underdeliver, and the number one reason isn't the technology — it's that people never adopt it. Forrester's analytics research has found that only about a third of employees actively use the BI tools their company pays for. If your marketing manager can't build a dashboard without filing a ticket, she'll go back to her spreadsheet, and your investment quietly dies. Pick the tool your team can use &lt;em&gt;this week&lt;/em&gt;, not the one that matches the skills you wish you had.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data connections decide whether it works at all.&lt;/strong&gt; Your numbers are probably scattered — some in a database, some in Stripe, some in a support tool. A good BI tool connects to those sources directly, without you exporting CSVs by hand every Monday. If your core data sits in a SQL database (Postgres, MySQL, and the like), make sure any tool you consider connects to it cleanly, because that's usually where the real answers live.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple way to compare, without a spreadsheet marathon
&lt;/h2&gt;

&lt;p&gt;You don't need a 40-row comparison matrix. Score two or three shortlisted tools from 1 to 5 on the five criteria, weighted for your situation. Something like this is plenty:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Tool A&lt;/th&gt;
&lt;th&gt;Tool B&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Ease of use&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Time to value&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Data connections&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Pricing that scales&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Governance&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The point isn't the exact math — it's forcing yourself to rate the things that matter instead of being dazzled by a slick demo. If a tool scores a 2 on ease of use, no amount of fancy charting saves it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run a real trial before you commit
&lt;/h2&gt;

&lt;p&gt;Demos are theater. The salesperson drives, the data is perfect, and everything works. You learn almost nothing about how the tool behaves with &lt;em&gt;your&lt;/em&gt; messy data and &lt;em&gt;your&lt;/em&gt; actual team.&lt;/p&gt;

&lt;p&gt;So run a short proof of concept. Connect your real data, pick one question that matters to the business, and see how far a non-expert on your team can get in an afternoon. A few concrete tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Connect your production database (read-only) and build a "revenue this month vs. last month" chart.&lt;/li&gt;
&lt;li&gt;Hand the tool to someone non-technical and ask them to change a date range without help.&lt;/li&gt;
&lt;li&gt;Try to give a teammate access to &lt;em&gt;some&lt;/em&gt; dashboards but not others.&lt;/li&gt;
&lt;li&gt;Add up what it would cost when your headcount doubles.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the tool passes those four tests, you've learned more than any demo could tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes that quietly kill BI projects
&lt;/h2&gt;

&lt;p&gt;Most bad BI decisions come from the same handful of errors. Watch for these:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buying for the team you wish you had.&lt;/strong&gt; Choosing a powerful, complex tool because someday you'll hire a data team leads to low adoption right now. Buy for the people in the room today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Judging cost by the sticker price.&lt;/strong&gt; The monthly fee is only part of the total cost of ownership. Factor in setup time, ongoing maintenance, and — the big one for small teams — how pricing grows. A tool that's $20/user feels cheap at five people and painful at fifty. Model both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skipping the trial.&lt;/strong&gt; As long as the spreadsheet still works, people keep using it. If you don't prove the new tool is easier &lt;em&gt;during&lt;/em&gt; evaluation, it won't magically become easier after you buy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over-engineering for scale you may never reach.&lt;/strong&gt; The flip side of buying too small is buying an enterprise platform "to be safe." The best tool for a 30-person company is rarely the best tool at 200 people, and vice versa. Aim for where you'll be in roughly 18 months — not 5 years, and not today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring who sees what.&lt;/strong&gt; It's tempting to skip permissions early on. But the day you show a customer's dashboard to the wrong customer, or a junior hire stumbles onto salary data, you'll wish you'd checked for row-level security and role-based access up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where different tools fit
&lt;/h2&gt;

&lt;p&gt;You don't have to pick a category blind. Broadly, small teams land in one of a few buckets: free, lightweight tools (great for marketing dashboards and getting started), self-service BI platforms (a balance of power and approachability for most growing teams), and heavier enterprise suites (usually overkill until you have a dedicated data team). If your data lives in a SQL database and you want dashboards and reports without a big setup, a self-service tool pointed straight at that database — &lt;a href="https://draxlr.com" rel="noopener noreferrer"&gt;Draxlr&lt;/a&gt; is one example — often gets a small team from raw tables to a live dashboard quickly. The category matters more than any single brand: match it to your team, not to a leaderboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Choosing a BI tool comes down to a few disciplined moves. Write down your requirements before you look at features. Judge every option on ease of use, time to value, data connections, pricing that scales, and governance — in that order for a small team. Score your shortlist instead of admiring demos. And above all, run a real trial with your own data and your own people, because adoption is where most BI projects live or die. Get those right and you'll pick a tool your team actually opens on Monday morning — which is the only measure of a BI tool that counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;How did you choose your current analytics tool — and would you make the same call again? What's the one criterion you underrated the first time around? Drop your experience (and the tools you've loved or abandoned) in the comments — small teams learn best from each other's mistakes.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>business</category>
      <category>startup</category>
      <category>data</category>
    </item>
    <item>
      <title>Read-Only Isn't Enough: Query Guardrails That Keep AI From Taking Down Your Database</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Mon, 21 Sep 2026 02:24:51 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/read-only-isnt-enough-query-guardrails-that-keep-ai-from-taking-down-your-database-295d</link>
      <guid>https://dev.to/vivekdraxlr/read-only-isnt-enough-query-guardrails-that-keep-ai-from-taking-down-your-database-295d</guid>
      <description>&lt;p&gt;You did the responsible thing. Before pointing Claude, Cursor, or an in-app assistant at your database, you created a read-only role. &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt; — all rejected. The AI can look but not touch. Safe, right?&lt;/p&gt;

&lt;p&gt;Not quite. Read-only protects your &lt;em&gt;data&lt;/em&gt; from being modified. It does nothing to protect your &lt;em&gt;database server&lt;/em&gt; from being overwhelmed. And the way large language models write SQL — confidently, sometimes with a missing join condition or a forgotten &lt;code&gt;WHERE&lt;/code&gt; — makes them very good at producing queries that are perfectly valid, perfectly read-only, and perfectly capable of pinning your CPU to 100% at 2pm on a Tuesday.&lt;/p&gt;

&lt;p&gt;This is the guardrail layer most teams skip. It's not about permissions; it's about &lt;em&gt;resource limits&lt;/em&gt;. Below are the guardrails that matter, why each one exists, and how to actually set them up. They're vendor-neutral and apply whether you're wiring up a raw connection, an MCP server, or an internal reporting tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode read-only doesn't cover
&lt;/h2&gt;

&lt;p&gt;Imagine an AI assistant is asked, "How many orders did each customer place last year?" It knows there's an &lt;code&gt;orders&lt;/code&gt; table and a &lt;code&gt;customers&lt;/code&gt; table, so it writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- The AI forgot the join condition&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comma join with no &lt;code&gt;ON&lt;/code&gt; clause is a &lt;strong&gt;cartesian product&lt;/strong&gt;. If you have 50,000 customers and 2 million orders, the database tries to build a 100-billion-row intermediate result before it ever gets to counting. It's a legal &lt;code&gt;SELECT&lt;/code&gt;. Your read-only role happily allows it. And it will chew through memory and CPU until something falls over.&lt;/p&gt;

&lt;p&gt;The same thing happens with an innocent-looking &lt;code&gt;SELECT * FROM events&lt;/code&gt; against a table with 500 million rows, or an unindexed &lt;code&gt;ORDER BY&lt;/code&gt; on a huge dataset. None of these are attacks. They're just the ordinary output of a model that doesn't know how big your tables are. So the job of the guardrail layer is to make sure that even a badly-shaped query fails &lt;em&gt;cheaply&lt;/em&gt; instead of expensively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrail 1: Cap every result set
&lt;/h2&gt;

&lt;p&gt;The first and simplest defense: never let a query return an unbounded number of rows. Even if the AI writes &lt;code&gt;SELECT * FROM events&lt;/code&gt;, wrap what it sends in an outer limit before executing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What the AI generated&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- What you actually run&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ai_query&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sensible default is somewhere between 500 and 1,000 rows. The AI almost never &lt;em&gt;needs&lt;/em&gt; more than that to answer a question or summarize a trend, and the cap protects both your database (less data to materialize and sort) and the process consuming the results from an out-of-memory surprise. Make the limit configurable, but always have one on by default. "No limit" should never be the default state for a query you didn't write yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrail 2: Kill runaway queries with a statement timeout
&lt;/h2&gt;

&lt;p&gt;A row limit caps what comes &lt;em&gt;back&lt;/em&gt;, but a cartesian join can burn resources long before it produces a single row. That's what a statement timeout is for: it tells the database to abort any query that runs longer than a set duration.&lt;/p&gt;

&lt;p&gt;In PostgreSQL you can set this per role, so it applies automatically to every session the AI opens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Any query from ai_readonly that runs longer&lt;/span&gt;
&lt;span class="c1"&gt;-- than 30 seconds gets terminated automatically&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'30s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL has an equivalent via &lt;code&gt;max_execution_time&lt;/code&gt; (as a query hint or system variable). The exact number depends on your workload — 30 seconds is a common starting point for interactive/analytical use — but the principle is universal: a query the AI wrote should never be able to run indefinitely. When it hits the ceiling, the database cancels it and returns a clean error the AI can react to, instead of silently degrading everything else on the box.&lt;/p&gt;

&lt;p&gt;Crunchy Data puts it well: a statement timeout ensures no connecting client "will have queries running longer than that." It's one line of config for an enormous reduction in blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrail 3: Point the AI at a read replica, not production
&lt;/h2&gt;

&lt;p&gt;The strongest structural guardrail isn't a setting — it's &lt;em&gt;which server the AI talks to&lt;/em&gt;. If the assistant only needs to read and analyze, it never has any business touching your primary database. Point it at a read replica instead.&lt;/p&gt;

&lt;p&gt;This buys you two things at once. First, exploratory and reporting queries don't compete with real user traffic for CPU, memory, or locks on your hot tables — a heavy analytical scan on the replica leaves checkout and login untouched on the primary. Second, a replica is read-only &lt;em&gt;by the laws of physics&lt;/em&gt;: a PostgreSQL standby will reject writes no matter what, so even if you fumbled the read-only role config, there's a hard floor under you. It's defense in depth.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Protects data from writes&lt;/th&gt;
&lt;th&gt;Protects production from load&lt;/th&gt;
&lt;th&gt;Operational cost&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Full-access role on primary&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Read-only role on primary&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Read-only role + timeout + row cap on primary&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partially&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Read replica with the same guardrails&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Higher&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A replica does add operational overhead — a second server, replication lag to monitor, failover to think about — so it's not always worth it. For a small database or a tiny team, a read-only role with a timeout and a row cap on the primary gets you most of the safety. But once your database matters and your AI usage is more than occasional, the replica is the cleanest boundary you can draw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrail 4: Constrain the connection itself
&lt;/h2&gt;

&lt;p&gt;Timeouts and row limits handle a single bad query. A connection limit handles &lt;em&gt;many&lt;/em&gt; bad queries. If an AI agent (or a customer-facing feature powered by one) can open unlimited concurrent sessions, a burst of activity can exhaust your connection pool and lock out your real application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Cap how many simultaneous connections&lt;/span&gt;
&lt;span class="c1"&gt;-- the AI's role can hold open&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt; &lt;span class="k"&gt;CONNECTION&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combine that with the statement timeout from earlier and, as one Postgres engineer put it, a "statement timeout and connection limit on a read-only role gives you 90% of the safety with 10% of the complexity." It's a great return on two lines of SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrail 5: Inspect the query shape (advanced)
&lt;/h2&gt;

&lt;p&gt;For higher-stakes setups, you can validate the &lt;em&gt;structure&lt;/em&gt; of a generated query before running it. A reporting query has a predictable shape: &lt;code&gt;SELECT ... FROM ... WHERE ...&lt;/code&gt;. If one suddenly references system catalogs, stacks multiple &lt;code&gt;UNION&lt;/code&gt;s, or joins a dozen tables, you can reject it at the application layer before it reaches the database. This is easy to over-engineer, so treat it as a layer you add when the earlier four aren't enough — the row cap, timeout, and replica cover the common cases with far less effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where MCP fits
&lt;/h2&gt;

&lt;p&gt;A lot of teams reach for the Model Context Protocol to connect AI clients to their databases, and a well-built MCP server is a natural home for these guardrails: the AI client sends a request, and the server — not the model — decides what actually runs. That's exactly the right place to enforce SELECT-only access, an outer row limit, and a statement timeout, so the same rules apply no matter which AI tool is on the other end.&lt;/p&gt;

&lt;p&gt;You can build this yourself, or use a managed MCP server that ships with the guardrails in place — Draxlr's, for example, is read-only (SELECT only) and sits between the AI and your database — so you inherit the safe defaults instead of hand-rolling them. Either way the point is the same: enforcement lives in the broker, where the model can't talk it out of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Assuming read-only means safe.&lt;/strong&gt; It means safe &lt;em&gt;from writes&lt;/em&gt;. Resource exhaustion is a separate problem with separate fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setting the timeout too high "just in case."&lt;/strong&gt; A 10-minute timeout on an interactive assistant defeats the purpose. If a query genuinely needs longer, run it deliberately, not through the AI's default path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting the timeout on the replica.&lt;/strong&gt; Replicas need their own &lt;code&gt;statement_timeout&lt;/code&gt; — the setting doesn't automatically follow from the primary. A runaway query on an unbounded replica is still a runaway query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relying on the AI to add its own &lt;code&gt;LIMIT&lt;/code&gt;.&lt;/strong&gt; It often will. It sometimes won't. Guardrails you enforce are the ones you can count on; guardrails you hope the model remembers are not guardrails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No connection ceiling.&lt;/strong&gt; Everyone tunes the single-query case and forgets that concurrency can take you down just as easily.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Read-only access is the &lt;em&gt;floor&lt;/em&gt;, not the ceiling, of safe AI database access. The queries that hurt you won't be malicious writes — they'll be well-meaning, valid &lt;code&gt;SELECT&lt;/code&gt;s that scan too much. Cap every result set with an outer &lt;code&gt;LIMIT&lt;/code&gt;, set a &lt;code&gt;statement_timeout&lt;/code&gt; so runaway queries die cheaply, put a &lt;code&gt;CONNECTION LIMIT&lt;/code&gt; on the role, and — when it's worth the cost — point the AI at a read replica so its worst query can't touch production. Enforce these in a broker or connection layer, not in the prompt, so they hold regardless of what the model writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;How are you protecting your database from AI-generated queries — read replica, tight timeouts, query inspection, or something else? Have you had an assistant produce a query that surprised you? Drop it in the comments; the war stories are usually the most useful part.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://www.crunchydata.com/blog/control-runaway-postgres-queries-with-statement-timeout" rel="noopener noreferrer"&gt;Crunchy Data — Control Runaway Postgres Queries With Statement Timeout&lt;/a&gt;, &lt;a href="https://rietta.com/blog/ai-sql-database-data-protection-read-replica/" rel="noopener noreferrer"&gt;Rietta — Protect Production SQL Databases from AI/LLM Agentic SQL Query Risks&lt;/a&gt;, &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Giving AI Access to Your Database? Hide the PII First</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Sun, 20 Sep 2026 09:26:09 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/giving-ai-access-to-your-database-hide-the-pii-first-5fhc</link>
      <guid>https://dev.to/vivekdraxlr/giving-ai-access-to-your-database-hide-the-pii-first-5fhc</guid>
      <description>&lt;p&gt;You wire up an AI assistant to your production database. You ask it a harmless question — "how many active users signed up last week?" — and it happily writes the SQL. Nice.&lt;/p&gt;

&lt;p&gt;But here's the part nobody thinks about until it bites them: that assistant can read &lt;em&gt;every column your connection can reach&lt;/em&gt;. &lt;code&gt;users.email&lt;/code&gt;. &lt;code&gt;customers.phone&lt;/code&gt;. &lt;code&gt;payments.card_last4&lt;/code&gt;. &lt;code&gt;patients.diagnosis&lt;/code&gt;. The moment you gave it a way to run &lt;code&gt;SELECT&lt;/code&gt;, you also gave it a way to pull personal data into a chat window, a log file, or an LLM provider's context — often without anyone intending it.&lt;/p&gt;

&lt;p&gt;This isn't a reason to keep AI away from your data. It's a reason to be deliberate about &lt;em&gt;which&lt;/em&gt; data it can see. The good news: the tools to do this already live in your database, and the patterns are the same whether the query comes from an AI agent, a BI tool, or a junior analyst. Let's walk through them, from the crudest to the cleanest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core problem: AI inherits your connection's blast radius
&lt;/h2&gt;

&lt;p&gt;An AI assistant querying your database is only as constrained as the credentials behind it. If it connects as a superuser or your app's main role, it sees everything that role sees. Security researchers reviewing the Model Context Protocol (MCP) — the standard many tools now use to connect AI to databases — repeatedly flag &lt;em&gt;over-permissioning&lt;/em&gt; as the fastest way these integrations go wrong: the connector exposes more than the task needs, and the agent returns data well beyond what you'd ever want in a prompt.&lt;/p&gt;

&lt;p&gt;So the first principle is boring but non-negotiable: &lt;strong&gt;least privilege&lt;/strong&gt;. The AI should connect through its own dedicated, read-only role that can touch only what it genuinely needs. Everything below builds on that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- A dedicated, read-only role for AI/analytics access&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;ai_reader&lt;/span&gt; &lt;span class="n"&gt;NOLOGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- No blanket access to the whole schema&lt;/span&gt;
&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ai_reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Grant only what's needed, table by table&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;analytics_events&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;ai_reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Layer 1: Column-level GRANT
&lt;/h2&gt;

&lt;p&gt;Most people know &lt;code&gt;GRANT SELECT ON table&lt;/code&gt;. Fewer know PostgreSQL (and MySQL 8+, with slightly different syntax) lets you grant &lt;code&gt;SELECT&lt;/code&gt; on &lt;em&gt;specific columns&lt;/em&gt;. If a table mixes public and sensitive fields — and most do — this is the sharpest tool you have.&lt;/p&gt;

&lt;p&gt;Say your &lt;code&gt;users&lt;/code&gt; table looks like this: &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;country&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;. The AI needs &lt;code&gt;country&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, and &lt;code&gt;created_at&lt;/code&gt; to answer product questions. It has no business reading &lt;code&gt;email&lt;/code&gt; or &lt;code&gt;phone&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Remove table-wide read access&lt;/span&gt;
&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ai_reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Grant only the safe columns&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;ai_reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now if the AI writes &lt;code&gt;SELECT email FROM users&lt;/code&gt;, the database itself rejects it with a permission error — before a single row of PII is touched. You didn't have to trust the model, the prompt, or the tool. The constraint lives where it belongs: in the database.&lt;/p&gt;

&lt;p&gt;The trade-off is bookkeeping. As tables grow, tracking who can see which column gets fiddly. A simple column access matrix keeps you honest:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;ai_reader&lt;/th&gt;
&lt;th&gt;support_role&lt;/th&gt;
&lt;th&gt;app_role&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;country, plan, created_at&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;td&gt;read/write&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;email, phone&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;td&gt;read/write&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;card_last4&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;read/write&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Layer 2: Masking views (keep the shape, hide the value)
&lt;/h2&gt;

&lt;p&gt;Sometimes you don't want to &lt;em&gt;hide&lt;/em&gt; a column — you want the AI to see a &lt;strong&gt;masked&lt;/strong&gt; version. It's useful for the model to know an email exists and group by its domain, without ever reading the real address. This is where views shine.&lt;/p&gt;

&lt;p&gt;Create a view that transforms sensitive columns, revoke access to the base table, and point the AI role only at the view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;users_ai&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;-- keep the domain, drop the local part&lt;/span&gt;
  &lt;span class="s1"&gt;'***@'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;split_part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'@'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;email_domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;-- last 2 digits only, for support triage&lt;/span&gt;
  &lt;span class="s1"&gt;'xxx-xxx-'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;right&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;phone_masked&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;   &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;     &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ai_reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt;  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users_ai&lt;/span&gt;  &lt;span class="k"&gt;TO&lt;/span&gt;   &lt;span class="n"&gt;ai_reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important detail: add &lt;code&gt;WITH (security_barrier)&lt;/code&gt; to the view. Without it, the planner can sometimes push a &lt;code&gt;WHERE&lt;/code&gt; clause &lt;em&gt;below&lt;/em&gt; your masking expression and leak the underlying value. The barrier forces your masking to run first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;users_ai&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security_barrier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here are the masking styles worth knowing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Nulling / redaction&lt;/td&gt;
&lt;td&gt;Replaces the value with NULL or a constant&lt;/td&gt;
&lt;td&gt;Fields AI should never read&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Partial masking&lt;/td&gt;
&lt;td&gt;Shows a fragment (domain, last 2 digits)&lt;/td&gt;
&lt;td&gt;Triage, grouping, dedup&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Substitution&lt;/td&gt;
&lt;td&gt;Swaps in realistic fake values&lt;/td&gt;
&lt;td&gt;Demos, shared environments&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Hashing&lt;/td&gt;
&lt;td&gt;Deterministic token instead of the value&lt;/td&gt;
&lt;td&gt;Joins/counts without exposure&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Layer 3: Add row-level security for two-dimensional control
&lt;/h2&gt;

&lt;p&gt;Column controls decide &lt;em&gt;which fields&lt;/em&gt;; row-level security (RLS) decides &lt;em&gt;which rows&lt;/em&gt;. Combine them and you get precise, two-dimensional access — the AI sees safe columns, and only the rows it's allowed to.&lt;/p&gt;

&lt;p&gt;This matters most for multi-tenant apps, where an AI feature answers questions for one customer and must never surface another customer's records.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;ai_reader&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the tenant ID set per session, the same AI query returns only that tenant's rows — no matter how the question is phrased. Column masking hides the &lt;em&gt;what&lt;/em&gt;; RLS hides the &lt;em&gt;whose&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: The connection layer matters too
&lt;/h2&gt;

&lt;p&gt;Database grants are the backstop, but a well-designed access layer between the AI and your database adds guardrails that SQL alone can't. The MCP security literature converges on a few:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-only enforcement.&lt;/strong&gt; Reject &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, and DDL outright, so an errant (or manipulated) prompt can't modify data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redaction in the data flow.&lt;/strong&gt; Scan tool responses and store only redacted representations when PII or secrets slip through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoped exposure.&lt;/strong&gt; Publish only approved tables and queries to the AI, not the whole schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auditability.&lt;/strong&gt; Log every query the AI runs, so access is reviewable and revocable — not scattered across long-lived credentials in prompts and config files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a big part of why managed database brokers exist. A gateway like &lt;a href="https://docs.draxlr.com/docs/mcp-server" rel="noopener noreferrer"&gt;Draxlr's MCP server&lt;/a&gt;, for example, connects over OAuth and is read-only by design, so the AI can explore schema and run &lt;code&gt;SELECT&lt;/code&gt;s without ever holding raw credentials or issuing a write. Whatever tool you use, the pattern is the point: put a controllable layer between the model and the database, and enforce PII rules there &lt;em&gt;and&lt;/em&gt; in the database itself. Defense in depth beats trusting any single boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connecting AI as the app's main user.&lt;/strong&gt; The single biggest error. Give it a dedicated, minimal, read-only role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Masking in the SELECT but leaving the base table readable.&lt;/strong&gt; If the role can still hit the base table, your view is theater. Revoke base access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;security_barrier&lt;/code&gt; on masking views.&lt;/strong&gt; The planner may leak the raw value through a pushed-down predicate. Set the barrier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Masking the value but not the &lt;code&gt;WHERE&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;WHERE email = 'jane@acme.com'&lt;/code&gt; in an AI-written query can confirm a specific person exists even if the output is masked. Restrict filtering on sensitive columns too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming "internal only" means safe.&lt;/strong&gt; Data pulled into an LLM context can be logged or cached downstream. Treat every AI query as if it might leave your walls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No audit trail.&lt;/strong&gt; If you can't answer "what did the AI read last Tuesday?", you can't prove compliance or catch abuse.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Giving AI access to your database is not the risky part — giving it &lt;em&gt;unscoped&lt;/em&gt; access is. Lead with least privilege and a dedicated read-only role. Use column-level &lt;code&gt;GRANT&lt;/code&gt; to hide sensitive fields entirely, and &lt;code&gt;security_barrier&lt;/code&gt; masking views when you want the shape without the value. Layer in row-level security for multi-tenant isolation, and put an auditable, read-only broker between the model and the database so the rules are enforced twice. Do that, and your AI assistant becomes genuinely useful &lt;em&gt;and&lt;/em&gt; something your security team can sign off on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;How are you handling PII when AI tools touch your database — column grants, masking views, a broker, or all three? Have you hit a gotcha I didn't list? Drop it in the comments; I'm collecting patterns and would love to hear what's working (or what blew up) in your setup.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The Dialect Trap: Why AI Writes SQL Your Database Refuses to Run</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Fri, 18 Sep 2026 01:02:03 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/the-dialect-trap-why-ai-writes-sql-your-database-refuses-to-run-5ci9</link>
      <guid>https://dev.to/vivekdraxlr/the-dialect-trap-why-ai-writes-sql-your-database-refuses-to-run-5ci9</guid>
      <description>&lt;p&gt;You ask an AI assistant for "the 10 most recent orders." It confidently hands you this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;TOP&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine — until you run it on PostgreSQL and get &lt;code&gt;syntax error at or near "10"&lt;/code&gt;. The logic was perfect. The &lt;em&gt;dialect&lt;/em&gt; was wrong. &lt;code&gt;TOP&lt;/code&gt; is SQL Server syntax; Postgres wants &lt;code&gt;LIMIT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is one of the most common and most frustrating failure modes of text-to-SQL. The query reads like valid SQL, passes a human eyeball test, and still bounces off your database because SQL isn't really one language — it's a family of closely-related dialects that disagree on the details. If you're building any kind of natural-language-to-SQL feature (or just pasting AI output into a query console), understanding the dialect trap will save you a lot of confusing error messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQL is a standard that nobody fully follows
&lt;/h2&gt;

&lt;p&gt;There's an ANSI SQL standard, but every database vendor extends and diverges from it. The core &lt;code&gt;SELECT ... FROM ... WHERE&lt;/code&gt; is portable. Almost everything interesting around it is not. Here's a small slice of where the major engines disagree:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;th&gt;SQL Server&lt;/th&gt;
&lt;th&gt;BigQuery&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Limit rows&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIMIT 10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIMIT 10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TOP 10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIMIT 10&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Concatenate strings&lt;/td&gt;
&lt;td&gt;&lt;code&gt;a || b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONCAT(a, b)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;a + b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONCAT(a, b)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Current timestamp&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOW()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOW()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GETDATE()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CURRENT_TIMESTAMP()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Extract month&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXTRACT(MONTH FROM d)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MONTH(d)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATEPART(month, d)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXTRACT(MONTH FROM d)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Quote an identifier&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"my col"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;`my col`&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[my col]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;`my col`&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these are cosmetic. Each one is the difference between a query that runs and a query that throws. And some differences are worse than a clean error: they run and quietly return the &lt;em&gt;wrong&lt;/em&gt; result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI gets this wrong so often
&lt;/h2&gt;

&lt;p&gt;Large language models learn SQL from the enormous pile of SQL on the public internet. Two things about that pile work against you.&lt;/p&gt;

&lt;p&gt;First, the training data is dominated by certain dialects. A lot of public text-to-SQL research and tutorial content is written against &lt;strong&gt;SQLite&lt;/strong&gt; and &lt;strong&gt;MySQL&lt;/strong&gt;, so models lean toward that syntax by default. Ask for a query without saying which database you use, and you'll often get SQLite-flavored or MySQL-flavored SQL — which may or may not match what you're actually running. One analysis found that &lt;strong&gt;roughly 32% of queries&lt;/strong&gt; from a popular text-to-SQL benchmark (Spider) threw syntax errors when executed against PostgreSQL, even though they were "correct" for the dialect they were generated in.&lt;/p&gt;

&lt;p&gt;Second, models blend dialects. Because the training data mixes every flavor together, a model can start a query in one dialect and finish it in another — using a Postgres &lt;code&gt;||&lt;/code&gt; concatenation in the &lt;code&gt;SELECT&lt;/code&gt; and a SQL Server &lt;code&gt;TOP&lt;/code&gt; in the same statement. The result is SQL that belongs to no real database.&lt;/p&gt;

&lt;p&gt;The model isn't "wrong" about SQL. It just doesn't know which of the several SQLs you meant, so it guesses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix starts with telling the model what it's talking to
&lt;/h2&gt;

&lt;p&gt;The single highest-leverage thing you can do is stop making the model guess. State the exact engine — and version — every time.&lt;/p&gt;

&lt;p&gt;Compare a vague prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Give me the 10 newest users.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with a dialect-anchored one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;You&lt;/span&gt; &lt;span class="k"&gt;are&lt;/span&gt; &lt;span class="n"&gt;writing&lt;/span&gt; &lt;span class="k"&gt;SQL&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;PostgreSQL&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="k"&gt;Return&lt;/span&gt; &lt;span class="k"&gt;only&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;no&lt;/span&gt; &lt;span class="n"&gt;explanation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="n"&gt;Give&lt;/span&gt; &lt;span class="n"&gt;me&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="n"&gt;newest&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="k"&gt;Schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second prompt reliably produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice two things are doing the work here. "PostgreSQL 16" pins the dialect. And the schema snippet — with real column &lt;em&gt;types&lt;/em&gt; — quietly reinforces it: &lt;code&gt;TIMESTAMPTZ&lt;/code&gt; and &lt;code&gt;TEXT&lt;/code&gt; are Postgres-isms, so the model stays in the right neighborhood. Sharing your schema isn't just about avoiding hallucinated column names; the types are a dialect signal too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a couple of examples in your dialect
&lt;/h2&gt;

&lt;p&gt;If you're building this into a product, a system prompt plus one or two worked examples (few-shot prompting) pins the dialect far more firmly than an instruction alone. You're showing, not just telling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Example (PostgreSQL):&lt;/span&gt;
&lt;span class="c1"&gt;-- Q: revenue per plan last month&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one example teaches the model your date-math idioms (&lt;code&gt;date_trunc&lt;/code&gt;, &lt;code&gt;interval&lt;/code&gt;), your casing conventions, and your dialect all at once. For a handful of canonical question shapes, a few pinned examples go a long way. For a large, varied set, retrieving the most relevant example pairs per question (a RAG approach) scales better than stuffing them all in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate before you trust — then let the error teach the model
&lt;/h2&gt;

&lt;p&gt;Even with a perfect prompt, treat generated SQL as a &lt;em&gt;draft&lt;/em&gt;. The cheapest safety net is to check the query compiles before you run it for real. Most engines give you a dry-run path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL / MySQL: ask the planner to parse &amp;amp; plan without returning rows&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- BigQuery supports an actual dry run that validates and estimates cost,&lt;/span&gt;
&lt;span class="c1"&gt;-- without scanning data or incurring charges.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the parse fails, don't just surface a raw error to your user — feed it back to the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;Your&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;PostgreSQL&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;datepart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;does&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;exist&lt;/span&gt;

&lt;span class="n"&gt;Rewrite&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;PostgreSQL&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;native&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This execution-feedback loop catches most dialect slips automatically. The model used &lt;code&gt;DATEPART&lt;/code&gt; (SQL Server); the error tells it exactly what the target engine rejected, and the retry almost always lands on &lt;code&gt;EXTRACT&lt;/code&gt; or &lt;code&gt;date_trunc&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A transpiler as a backstop
&lt;/h2&gt;

&lt;p&gt;Prompting reduces dialect errors; it doesn't eliminate them. If you need a hard guarantee, put a SQL transpiler between the model and the database. Libraries like &lt;code&gt;sqlglot&lt;/code&gt; can parse SQL written in one dialect and re-emit it in another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlglot&lt;/span&gt;

&lt;span class="c1"&gt;# Model produced SQL Server-flavored SQL; we run Postgres.
&lt;/span&gt;&lt;span class="n"&gt;generated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT TOP 10 name + &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;!&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; AS shout FROM users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlglot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transpile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tsql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# -&amp;gt; SELECT name || '!' AS shout FROM users LIMIT 10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now even if the model drifts into the wrong dialect, the SQL that reaches your database is normalized to the one you actually run. It also gives you a parse step for free: if the transpiler can't parse it, you reject it before execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas to watch for
&lt;/h2&gt;

&lt;p&gt;A few dialect traps burn people repeatedly, even when the basic syntax is right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Silent wrong answers, not errors.&lt;/strong&gt; Integer division is the classic one: &lt;code&gt;5 / 2&lt;/code&gt; is &lt;code&gt;2&lt;/code&gt; in PostgreSQL and SQL Server but &lt;code&gt;2.5&lt;/code&gt; in MySQL and BigQuery. No error — just a quietly wrong number. These are the dangerous cases validation won't catch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Case sensitivity of identifiers.&lt;/strong&gt; Postgres folds unquoted names to lowercase; MySQL's behavior depends on the OS and config. An AI that quotes &lt;code&gt;"UserID"&lt;/code&gt; can make a column that worked suddenly "not exist."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fully-qualified names in BigQuery.&lt;/strong&gt; BigQuery references tables as &lt;code&gt;`project.dataset.table`&lt;/code&gt; and uses &lt;code&gt;STRING&lt;/code&gt; instead of &lt;code&gt;VARCHAR&lt;/code&gt;, with no timezone-naive &lt;code&gt;TIMESTAMP&lt;/code&gt;. Models trained mostly on other engines routinely get this wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NULL&lt;/code&gt; handling and string funcs.&lt;/strong&gt; &lt;code&gt;CONCAT&lt;/code&gt; with a &lt;code&gt;NULL&lt;/code&gt; argument behaves differently across engines; so does sorting &lt;code&gt;NULL&lt;/code&gt;s. Don't assume portability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version drift.&lt;/strong&gt; &lt;code&gt;FETCH FIRST 10 ROWS ONLY&lt;/code&gt; works on modern SQL Server and Oracle but not older versions. Saying "SQL Server 2022," not just "SQL Server," matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;SQL's dialects are the hidden reason so much AI-generated SQL "looks right but doesn't run." To stay out of the trap:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Do this&lt;/th&gt;
&lt;th&gt;Why it helps&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Name the engine &lt;em&gt;and version&lt;/em&gt; in every prompt&lt;/td&gt;
&lt;td&gt;Stops the model from guessing or blending dialects&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Pass the schema with real column types&lt;/td&gt;
&lt;td&gt;Types are a dialect signal and kill hallucinated columns&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Give one or two examples in your dialect&lt;/td&gt;
&lt;td&gt;Shows idioms the model can't infer from an instruction&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Dry-run / EXPLAIN before executing&lt;/td&gt;
&lt;td&gt;Catches syntax errors cheaply, before users see them&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Feed execution errors back for a retry&lt;/td&gt;
&lt;td&gt;Self-corrects most dialect slips automatically&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Transpile as a backstop&lt;/td&gt;
&lt;td&gt;Guarantees the SQL that reaches the DB is the right flavor&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're wiring an AI assistant to a database through a schema-aware layer — a managed connector or MCP server, for example — much of this can be handled for you: the layer already knows which engine it's connected to and can pass that context (and the schema) along so the model writes for the right dialect from the start. That's a nicer place to solve the problem than re-explaining your database in every prompt.&lt;/p&gt;

&lt;p&gt;Have you been bitten by the dialect trap? I'm curious which engine trips up your AI tools the most — drop your worst "looked right, wouldn't run" query in the comments, and share the tricks that fixed it for you.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>One AI Assistant, Many Databases: Wiring Up Multiple Data Sources with MCP</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Thu, 17 Sep 2026 12:58:32 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/one-ai-assistant-many-databases-wiring-up-multiple-data-sources-with-mcp-35pm</link>
      <guid>https://dev.to/vivekdraxlr/one-ai-assistant-many-databases-wiring-up-multiple-data-sources-with-mcp-35pm</guid>
      <description>&lt;p&gt;You wired up an AI assistant to your database and it's great. You ask "what was MRR by plan last month?" and get back working SQL and an answer in seconds. Then reality sets in: that was your &lt;em&gt;analytics&lt;/em&gt; database. Your user records live in a Postgres replica. Your billing events sit in a separate MySQL box that a previous team stood up and nobody wants to touch. Real teams almost never have one database — they have a small constellation of them.&lt;/p&gt;

&lt;p&gt;So the interesting question isn't "can an AI query my database?" It's "how do I give one assistant safe, sane access to &lt;em&gt;all&lt;/em&gt; of my databases at once — and make sure it queries the right one, in the right dialect, without me babysitting every request?"&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; (MCP) is built for exactly this. Let's walk through the two ways to connect multiple databases, how the AI decides which one to use, the dialect trap that quietly breaks multi-database setups, and the mistakes that bite people along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick refresher: what MCP actually connects
&lt;/h2&gt;

&lt;p&gt;MCP is an open standard that lets AI clients — Claude, Cursor, ChatGPT, VS Code, and others — call tools through a consistent interface. The AI application is the &lt;strong&gt;host&lt;/strong&gt;. For every MCP &lt;strong&gt;server&lt;/strong&gt; it talks to, it spins up a dedicated &lt;strong&gt;client&lt;/strong&gt; connection. A database MCP server typically exposes a few tools like &lt;code&gt;list_databases&lt;/code&gt;, &lt;code&gt;get_schema&lt;/code&gt;, and &lt;code&gt;run_query&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The key idea: the AI never holds your credentials or speaks the Postgres wire protocol. It calls a tool by name, and the server does the real database work behind the credentials &lt;em&gt;it&lt;/em&gt; holds. That indirection is what makes connecting several databases tractable — you're composing tool calls, not juggling connection strings in a chat window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to connect many databases
&lt;/h2&gt;

&lt;p&gt;There are two patterns, and the right one depends on how uniform your databases are.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern A: one MCP server per database
&lt;/h3&gt;

&lt;p&gt;You run a separate server for each database and register them all with your AI client. Configuration looks like a list of servers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&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;"users-postgres"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgres-mcp-server"&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;"DATABASE_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgres://readonly:***@replica.internal:5432/users"&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;span class="nl"&gt;"analytics-warehouse"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgres-mcp-server"&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;"DATABASE_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgres://readonly:***@warehouse.internal:5432/analytics"&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;span class="nl"&gt;"billing-mysql"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mysql-mcp-server"&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;"DATABASE_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mysql://readonly:***@billing.internal:3306/billing"&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;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;This is flexible — each database can use a purpose-built server — but it has a well-known sharp edge: &lt;strong&gt;tool name collisions&lt;/strong&gt;. If all three servers expose a tool called &lt;code&gt;run_query&lt;/code&gt;, the host ends up with three tools of the same name in one aggregated list, and it can call the wrong one. The MCP spec acknowledges this and leaves disambiguation to the host. In practice, hosts and proxies solve it by &lt;em&gt;namespacing&lt;/em&gt; tools with the server name — &lt;code&gt;billing-mysql.run_query&lt;/code&gt; vs. &lt;code&gt;users-postgres.run_query&lt;/code&gt; — so each call routes to the intended server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern B: one gateway server fronting many databases
&lt;/h3&gt;

&lt;p&gt;The alternative is a single MCP server that knows about all your databases and takes the database as a &lt;em&gt;parameter&lt;/em&gt;. Instead of three &lt;code&gt;run_query&lt;/code&gt; tools, you get one, plus a &lt;code&gt;list_databases&lt;/code&gt; tool the AI calls first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tools exposed:
  list_databases()            -&amp;gt; [ {id, name, engine}, ... ]
  get_schema(database_id)     -&amp;gt; tables + columns for that DB
  run_query(database_id, sql) -&amp;gt; rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical session flows: the AI calls &lt;code&gt;list_databases&lt;/code&gt;, sees your three sources, picks the relevant one, pulls its schema, then runs a query scoped to that &lt;code&gt;database_id&lt;/code&gt;. No collisions, one connection to manage, and one place to enforce read-only access and auditing. Managed MCP servers tend to use this shape — for example, &lt;a href="https://docs.draxlr.com/docs/mcp-server" rel="noopener noreferrer"&gt;Draxlr&lt;/a&gt; exposes a single read-only (SELECT-only) endpoint over OAuth that lists your connected databases and runs schema/query calls per database id. Whichever you pick, the gateway pattern usually scales better once you're past two or three databases.&lt;/p&gt;

&lt;p&gt;Here's the trade-off at a glance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Server per database&lt;/th&gt;
&lt;th&gt;One gateway server&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Tool name collisions&lt;/td&gt;
&lt;td&gt;Possible; needs namespacing&lt;/td&gt;
&lt;td&gt;None — database is a parameter&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Adding a database&lt;/td&gt;
&lt;td&gt;Edit client config, restart&lt;/td&gt;
&lt;td&gt;Register it once in the gateway&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Central audit / read-only&lt;/td&gt;
&lt;td&gt;Per server&lt;/td&gt;
&lt;td&gt;One enforcement point&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Mixing DB engines&lt;/td&gt;
&lt;td&gt;Easy — different server each&lt;/td&gt;
&lt;td&gt;Depends on gateway support&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;2–3 heterogeneous databases&lt;/td&gt;
&lt;td&gt;Many databases, shared access&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How does the AI pick the right database?
&lt;/h2&gt;

&lt;p&gt;This is where good setups separate from frustrating ones. The AI routes based on &lt;strong&gt;names and descriptions&lt;/strong&gt;, not magic. A database registered as &lt;code&gt;db_47&lt;/code&gt; with no description is a coin flip; one called &lt;code&gt;analytics_warehouse&lt;/code&gt; described as "aggregated events and revenue, read replica, updated hourly" gives the model everything it needs to choose correctly.&lt;/p&gt;

&lt;p&gt;So when you have a question that could plausibly hit two sources, name and describe your databases like you're onboarding a new analyst. Then a prompt like this resolves cleanly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How many trial users from the &lt;code&gt;users&lt;/code&gt; database converted to paid, using the subscription events in the &lt;code&gt;billing&lt;/code&gt; database?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The assistant recognizes it needs two sources. Since most databases can't join across a network boundary, it does what a human would: query each separately and combine. First, against the users source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- users-postgres&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'trial'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, against billing, filtering to those ids:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- billing-mysql&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscription_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'subscription_started'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ids from the previous step */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assistant stitches the two result sets together and reports the count. The important behavior: it did &lt;strong&gt;not&lt;/strong&gt; try to write one query joining &lt;code&gt;users.users&lt;/code&gt; to &lt;code&gt;billing.subscription_events&lt;/code&gt; — those live in different engines on different hosts, and no single query spans them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dialect trap
&lt;/h2&gt;

&lt;p&gt;Here's the gotcha that surprises people: the same English question produces &lt;em&gt;different SQL&lt;/em&gt; depending on which database answers it. Ask "revenue by month" against Postgres and MySQL and the correct queries are not identical.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Extract the month&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXTRACT(MONTH FROM created_at)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MONTH(created_at)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Null fallback&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COALESCE(x, 0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IFNULL(x, 0)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Concatenate group&lt;/td&gt;
&lt;td&gt;&lt;code&gt;STRING_AGG(name, ', ')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GROUP_CONCAT(name)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Quote an identifier&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"order"&lt;/code&gt; (double quotes)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;`order`&lt;/code&gt; (backticks)&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is exactly why schema-awareness matters so much in a multi-database setup. When the MCP server tells the AI not just the table shapes but &lt;em&gt;which engine&lt;/em&gt; each database runs, the model targets the right dialect. When it doesn't, you get Postgres syntax fired at MySQL and a confusing error. If your assistant keeps generating queries in the wrong dialect, that's usually the tell that the engine isn't being surfaced in the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Assuming the AI can join across databases.&lt;/strong&gt; It can't run a single query across two separate engines. Expect a query-each-then-combine approach, and know that huge intermediate result sets (a million ids in an &lt;code&gt;IN (...)&lt;/code&gt; list) will be slow or get truncated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vague database names.&lt;/strong&gt; &lt;code&gt;db1&lt;/code&gt;, &lt;code&gt;db2&lt;/code&gt;, &lt;code&gt;prod_copy_final&lt;/code&gt; — the model can't route what it can't understand. Descriptive names and one-line descriptions are the cheapest accuracy win available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pointing at primary databases.&lt;/strong&gt; Exploratory AI queries can be heavy and unpredictable. Connect read replicas, and keep access read-only so an experimental query can never issue an &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DROP&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring tool-name collisions.&lt;/strong&gt; In the server-per-database pattern, verify your host namespaces tools. Two &lt;code&gt;run_query&lt;/code&gt; tools with no prefix is a silent routing bug waiting to happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dumping every schema into context at once.&lt;/strong&gt; Ten databases with hundreds of tables each can blow past the model's context window. Prefer a &lt;code&gt;list_databases&lt;/code&gt; → &lt;code&gt;get_schema(one_db)&lt;/code&gt; flow that fetches schema on demand rather than loading everything up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Connecting multiple databases to one AI assistant comes down to a few decisions. Use a server per database for a handful of heterogeneous sources, or a single gateway that takes the database as a parameter once you have many. Name and describe every database clearly so the AI routes correctly. Respect dialect differences by making sure each database's engine is part of what the AI sees. Don't expect cross-engine joins — expect query-and-combine. And keep every connection read-only against a replica so exploration stays safe.&lt;/p&gt;

&lt;p&gt;Get those right and you go from "the AI can talk to &lt;em&gt;a&lt;/em&gt; database" to "the AI understands our whole data estate" — which is where it actually starts saving your team time.&lt;/p&gt;

&lt;p&gt;How many databases would your assistant need to reach to be genuinely useful — and are they named well enough for it to tell them apart? I'd love to hear how you've wired up multi-database access, and what tripped you up. Drop a comment.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Local vs. Remote MCP Servers for Your Database: Which One Should You Actually Use?</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Fri, 11 Sep 2026 06:37:02 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/local-vs-remote-mcp-servers-for-your-database-which-one-should-you-actually-use-17oa</link>
      <guid>https://dev.to/vivekdraxlr/local-vs-remote-mcp-servers-for-your-database-which-one-should-you-actually-use-17oa</guid>
      <description>&lt;p&gt;You wired up an AI assistant to your database last week. It reads your schema, writes surprisingly good SQL, and answers questions like "what was MRR by plan last month?" in seconds. It works great — on your laptop.&lt;/p&gt;

&lt;p&gt;Then a teammate asks for the same thing. Or you want it running in CI. Or your security lead asks where, exactly, the production database password is stored. Suddenly the setup that felt magical raises a real architecture question: should the &lt;strong&gt;MCP server&lt;/strong&gt; that connects the AI to your database run &lt;strong&gt;locally&lt;/strong&gt; on each machine, or &lt;strong&gt;remotely&lt;/strong&gt; as a shared service?&lt;/p&gt;

&lt;p&gt;This is the fork in the road most teams hit once "AI can talk to our database" stops being a demo and starts being infrastructure. The two options behave very differently in terms of speed, security, and who can use them. Let's break down how each works, when each makes sense, and the hybrid pattern that tends to win in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick refresher: what an MCP server does for a database
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; (MCP) is an open standard that lets AI clients — Claude, Cursor, ChatGPT, VS Code, and others — call tools through a consistent interface. An MCP server for a database typically exposes a handful of tools like &lt;code&gt;list_tables&lt;/code&gt;, &lt;code&gt;get_schema&lt;/code&gt;, and &lt;code&gt;run_query&lt;/code&gt;. The AI doesn't hold your credentials or speak Postgres wire protocol; it calls a tool, and the server does the actual database work behind the credentials &lt;em&gt;it&lt;/em&gt; holds.&lt;/p&gt;

&lt;p&gt;That indirection is the whole point. But &lt;em&gt;where&lt;/em&gt; that server runs — and how the client talks to it — is exactly what "local vs. remote" decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two transports: stdio and Streamable HTTP
&lt;/h2&gt;

&lt;p&gt;MCP defines two standard transports, and they map almost one-to-one onto local vs. remote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;stdio&lt;/strong&gt; is for local servers. The client launches the server as a child process and talks to it over standard input/output — JSON-RPC messages in, JSON-RPC messages out. No network, no ports. It's the natural fit when the server and client live on the same machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streamable HTTP&lt;/strong&gt; is for remote servers. The server runs as an independent, long-lived process that clients reach over HTTP (optionally streaming responses via Server-Sent Events). It's built for network access, multiple concurrent clients, and central hosting. It replaced the older HTTP+SSE transport.&lt;/p&gt;

&lt;p&gt;Here's the practical contrast:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;stdio (local)&lt;/th&gt;
&lt;th&gt;Streamable HTTP (remote)&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Where it runs&lt;/td&gt;
&lt;td&gt;Same machine as the AI client&lt;/td&gt;
&lt;td&gt;A shared host or cloud service&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Concurrent users&lt;/td&gt;
&lt;td&gt;One client, one process&lt;/td&gt;
&lt;td&gt;Many clients at once&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Auth model&lt;/td&gt;
&lt;td&gt;Reads credentials from the local environment&lt;/td&gt;
&lt;td&gt;OAuth 2.1 at the transport layer&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Audit / central control&lt;/td&gt;
&lt;td&gt;None built in&lt;/td&gt;
&lt;td&gt;Single point to log and revoke&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;Near-zero (no network hop)&lt;/td&gt;
&lt;td&gt;Network round-trip&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Solo dev work, local/dev databases&lt;/td&gt;
&lt;td&gt;Teams, customers, production data&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The spec's own guidance: clients should support stdio whenever possible, but you switch to Streamable HTTP the moment the server needs to be reachable by more than one machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local MCP servers: fast, private, single-player
&lt;/h2&gt;

&lt;p&gt;A local server runs on your box and connects to a database it can reach directly. Configuration usually looks like a small block in your AI client's settings that tells it how to launch the process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&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;"analytics-db"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"some-postgres-mcp-server"&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;"DATABASE_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgres://readonly:***@localhost:5432/appdb"&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;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;Now you can ask questions in plain English and the server turns them into SQL against your schema:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; How many trial users converted to paid last month?&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;converted_users&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trial_started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-08-01'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-08-01'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="s1"&gt;'2026-09-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; converted_users
-----------------
             418
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a great developer experience: zero network latency, nothing leaves your machine, and it's dead simple to spin up against a local or dev database. For solo exploration and building queries, local is hard to beat.&lt;/p&gt;

&lt;p&gt;The catch shows up when you try to scale it to &lt;em&gt;people&lt;/em&gt;. stdio has no transport-layer authentication, so it inherits whatever credentials sit in your local environment. Every teammate who wants the same capability copies a config file with a connection string in it. There's no central audit log, no shared query cache, and no single place to rotate a leaked password. It's single-player by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remote MCP servers: shared, governed, multi-player
&lt;/h2&gt;

&lt;p&gt;A remote server flips the model. It runs once, as a service, and every AI client connects to the same endpoint over HTTP — authenticating with OAuth instead of a pasted connection string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&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;"analytics-db"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.internal.example.com/mcp"&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;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;The first time a client connects, it runs an OAuth flow and receives a short-lived token. The database credentials never touch the client, the prompt, or anyone's config file — they live only on the server. Because there's one gateway, you also get the things stdio can't offer: a shared connection pool, centralized audit logs of every query the AI ran, per-user or per-tenant scoping, and one-click revocation when someone leaves the team.&lt;/p&gt;

&lt;p&gt;Managed options exist here too. &lt;a href="https://docs.draxlr.com/docs/mcp-server" rel="noopener noreferrer"&gt;Draxlr&lt;/a&gt;, for example, runs a hosted MCP server you connect as a read-only OAuth custom connector — one implementation of the remote pattern where the server holds the credentials and only &lt;code&gt;SELECT&lt;/code&gt; gets through. Whether you self-host or buy, the defining trait of the remote model is the same: the database stops being directly exposed to every developer's laptop, which shrinks your attack surface considerably.&lt;/p&gt;

&lt;p&gt;The trade-off is operational weight. Someone has to run that service, keep OAuth configured correctly, and monitor it. There's a real network hop. And a misconfigured public endpoint is a much bigger deal than a stdio process that only you can launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  A decision framework
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;If you…&lt;/th&gt;
&lt;th&gt;Lean toward&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Are one developer exploring a local or dev database&lt;/td&gt;
&lt;td&gt;Local (stdio)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Need the lowest possible latency and full privacy&lt;/td&gt;
&lt;td&gt;Local (stdio)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Want several teammates to share the same access&lt;/td&gt;
&lt;td&gt;Remote (HTTP)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Must keep production credentials off end-user machines&lt;/td&gt;
&lt;td&gt;Remote (HTTP)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Need audit logs, revocation, or per-tenant scoping&lt;/td&gt;
&lt;td&gt;Remote (HTTP)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Are exposing data to customers or non-technical staff&lt;/td&gt;
&lt;td&gt;Remote (HTTP)&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is simple: &lt;strong&gt;local optimizes for one developer's speed and privacy; remote optimizes for a team's governance and reach.&lt;/strong&gt; The more people and the more sensitive the data, the further you slide toward remote.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hybrid setup most teams land on
&lt;/h2&gt;

&lt;p&gt;You don't have to pick one forever. A very common progression:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Develop locally.&lt;/strong&gt; Run a stdio server against a dev or read-replica database while you build and test queries. Fast feedback, no infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Promote to remote.&lt;/strong&gt; Once a workflow is worth sharing, put it behind a remote HTTP server with OAuth so the whole team — and their various AI clients — hit the same governed endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep production remote-only.&lt;/strong&gt; Never let a stdio config with production credentials float around laptops. Production data lives behind the shared, audited gateway.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This gives you the local dev loop &lt;em&gt;and&lt;/em&gt; the centralized control, without forcing everyone through a network hop while they're just iterating on a query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Putting production credentials in a stdio config.&lt;/strong&gt; The single most common footgun. A &lt;code&gt;DATABASE_URL&lt;/code&gt; in a local config is fine for a dev database and dangerous for production — it's a plaintext secret that gets copied, committed, and forgotten. Keep production behind a remote server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assuming stdio is "secure" because it's local.&lt;/strong&gt; Local means private &lt;em&gt;to that machine&lt;/em&gt;, not governed. There's no audit trail and no way to revoke access short of changing the database password for everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Standing up a remote server without OAuth.&lt;/strong&gt; A remote MCP endpoint without proper authorization is just an unauthenticated database proxy on the internet. If you go remote, OAuth 2.1 (short-lived tokens, central revocation) is table stakes, not a nice-to-have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting read-only.&lt;/strong&gt; Whichever model you choose, scope the database role the server uses to &lt;code&gt;SELECT&lt;/code&gt; only. An AI exploring your data should never be one hallucinated &lt;code&gt;DELETE&lt;/code&gt; away from a bad day. Enforce it at the database &lt;em&gt;and&lt;/em&gt; the server layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring multi-tenancy.&lt;/strong&gt; If customers or different teams share one remote server, a single missing &lt;code&gt;WHERE tenant_id = ...&lt;/code&gt; leaks everyone's data. Push tenant isolation into row-level security or scoped credentials — don't rely on the AI to remember it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;MCP's two transports map cleanly onto the choice: &lt;strong&gt;stdio for local, Streamable HTTP for remote.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local&lt;/strong&gt; wins for a single developer: fast, private, trivial to set up against dev databases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote&lt;/strong&gt; wins for teams and production: OAuth instead of pasted credentials, central audit and revocation, one endpoint for many AI clients, and a database that isn't exposed to every laptop.&lt;/li&gt;
&lt;li&gt;Most teams use &lt;strong&gt;both&lt;/strong&gt; — build locally, share and ship remotely, keep production strictly behind the gateway.&lt;/li&gt;
&lt;li&gt;Regardless of model: &lt;strong&gt;read-only roles, OAuth for anything networked, and real tenant isolation.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;How are you running MCP against your database today — a local stdio server per developer, a shared remote one, or some hybrid? What tipped you from one to the other? Drop your setup (and your favorite gotcha) in the comments — I'd love to hear what's working for other teams.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Share the Schema, Not the Password: How Schema-Aware AI Stops Inventing Columns</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Sun, 06 Sep 2026 04:04:34 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/share-the-schema-not-the-password-how-schema-aware-ai-stops-inventing-columns-5ei7</link>
      <guid>https://dev.to/vivekdraxlr/share-the-schema-not-the-password-how-schema-aware-ai-stops-inventing-columns-5ei7</guid>
      <description>&lt;p&gt;You ask an AI assistant, "How many active subscriptions did we add last month?" It confidently returns a query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;signup_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-08-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean, readable, and completely wrong for your database. Your table calls the column &lt;code&gt;created_at&lt;/code&gt;, not &lt;code&gt;signup_date&lt;/code&gt;. There is no &lt;code&gt;status&lt;/code&gt; column — you track state in &lt;code&gt;canceled_at IS NULL&lt;/code&gt;. The query either fails loudly with &lt;code&gt;column "signup_date" does not exist&lt;/code&gt;, or worse, it runs against a lookalike column and hands you a confident, incorrect number that nobody catches until it's in a board deck.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;hallucinated column&lt;/strong&gt;, and it happens for one simple reason: the model was guessing. It never saw your schema, so it invented plausible names based on the thousands of SaaS databases in its training data. The fix is not a cleverer prompt. It's giving the model the one thing it was missing — your actual schema — without handing over the keys to the whole database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the model guesses in the first place
&lt;/h2&gt;

&lt;p&gt;An LLM is a pattern machine. Ask it for SQL and give it nothing but the question, and it will produce SQL shaped like the most common answer to questions like yours. If most &lt;code&gt;subscriptions&lt;/code&gt; tables in its training data have a &lt;code&gt;status&lt;/code&gt; column, that's what you get — regardless of what your table actually looks like.&lt;/p&gt;

&lt;p&gt;There are two obvious but bad ways to fix this. The first is to paste your schema into the chat by hand every time. That works for one query and falls apart by the third, and it goes stale the moment someone runs a migration. The second is to hand the AI a live database connection string so it can look things up itself. That "fixes" accuracy by creating a much bigger problem: now an AI tool holds credentials that can read every row of PII and write to production, and your chat log is one screenshot away from leaking them.&lt;/p&gt;

&lt;p&gt;The better framing separates two things that usually travel together:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;What the AI actually needs&lt;/th&gt;
&lt;th&gt;What it does NOT need&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Table names, column names, and types&lt;/td&gt;
&lt;td&gt;Your database password&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Primary and foreign keys&lt;/td&gt;
&lt;td&gt;Network access to the DB host&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Constraints and relationships&lt;/td&gt;
&lt;td&gt;Write or DDL permissions&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Enough structure to write correct SQL&lt;/td&gt;
&lt;td&gt;The ability to read raw rows unsupervised&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To write correct SQL, the model needs your &lt;strong&gt;schema&lt;/strong&gt;. It does not need your &lt;strong&gt;credentials&lt;/strong&gt;. Once you see that split, the design almost writes itself: share the map, keep the keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema-awareness: grounding the model in reality
&lt;/h2&gt;

&lt;p&gt;The term for feeding the model your real structure before it writes anything is &lt;strong&gt;schema grounding&lt;/strong&gt; (or schema-aware reasoning). Instead of asking "what would a &lt;code&gt;subscriptions&lt;/code&gt; query usually look like," the model first reads the actual definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What the model reads before writing a single line of SQL&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;account_id&lt;/span&gt;   &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;plan&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;   &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;canceled_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;            &lt;span class="c1"&gt;-- NULL means still active&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same question produces SQL grounded in what exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;canceled_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-08-01'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="s1"&gt;'2026-09-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;status&lt;/code&gt;. No &lt;code&gt;signup_date&lt;/code&gt;. The model can't invent &lt;code&gt;region&lt;/code&gt; or &lt;code&gt;revenue&lt;/code&gt; out of thin air, because the boundaries of what it's allowed to reference are set by the schema in front of it. Grounding doesn't make the model smarter — it makes the space of possible answers smaller and truer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the connection layer comes in
&lt;/h2&gt;

&lt;p&gt;Manually pasting that &lt;code&gt;CREATE TABLE&lt;/code&gt; block works for a demo. In real life you want the schema delivered automatically, kept current, and delivered &lt;em&gt;without&lt;/em&gt; also delivering the credentials. That's exactly the job of a database connector layer — and it's the core idea behind the &lt;strong&gt;Model Context Protocol (MCP)&lt;/strong&gt;, the open standard for connecting AI clients to tools and data sources.&lt;/p&gt;

&lt;p&gt;The pattern looks like this: a small server sits between the AI client and your database. It holds the connection details. It exposes a couple of capabilities to the AI — typically a way to &lt;em&gt;read the schema&lt;/em&gt; and a way to &lt;em&gt;run a read-only query&lt;/em&gt; — and nothing else. When the AI needs to write SQL, it asks the server for the schema, gets back real table and column definitions as structured data, and generates its query against that. When it runs the query, the server executes it on a read-only connection and returns rows.&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;// A generic, vendor-neutral connector config — note there is no&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// password anywhere near the AI client&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;"mcpServers"&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;"analytics-db"&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://your-broker.example.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth"&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;// token brokered here, never pasted into a prompt&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;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;A typical exchange, in plain terms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Who does it&lt;/th&gt;
&lt;th&gt;What crosses the wire&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;1. "List the tables"&lt;/td&gt;
&lt;td&gt;AI → broker&lt;/td&gt;
&lt;td&gt;Table names only&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2. "Describe &lt;code&gt;subscriptions&lt;/code&gt;"&lt;/td&gt;
&lt;td&gt;AI → broker&lt;/td&gt;
&lt;td&gt;Columns, types, keys&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;3. Generate SQL&lt;/td&gt;
&lt;td&gt;AI (locally)&lt;/td&gt;
&lt;td&gt;Nothing — it's grounded now&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;4. Run the SELECT&lt;/td&gt;
&lt;td&gt;broker → DB&lt;/td&gt;
&lt;td&gt;Rows back, read-only&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The AI got everything it needed to be accurate and nothing it could use to be dangerous. Managed brokers like Draxlr's MCP server implement exactly this shape — OAuth-based, read-only (SELECT only), able to hand the model your schema and run queries without the assistant ever seeing a credential — but the pattern is the point, and you can build a minimal version of it yourself in an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security dividend
&lt;/h2&gt;

&lt;p&gt;Schema-sharing started as an accuracy fix, but it quietly solves a security problem too. Because the AI never receives credentials:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There are &lt;strong&gt;no long-lived secrets&lt;/strong&gt; sitting in prompts, chat history, or config files that get committed to a repo by accident.&lt;/li&gt;
&lt;li&gt;Access is &lt;strong&gt;centrally revocable&lt;/strong&gt; — kill the token at the broker and every connected client loses access at once, no credential rotation across a dozen laptops.&lt;/li&gt;
&lt;li&gt;The database is &lt;strong&gt;not network-exposed&lt;/strong&gt; to every machine running an AI client; only the broker talks to it.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;read-only, SELECT-only&lt;/strong&gt; connection means even a perfectly-worded "delete all canceled accounts" request has nowhere to land.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get more accurate SQL &lt;em&gt;and&lt;/em&gt; a smaller attack surface from the same architectural decision. That's rare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Dumping a 400-table schema into context.&lt;/strong&gt; Schema grounding consumes tokens, and a giant schema can blow past the context window or bury the relevant tables in noise. Expose a focused subset, or let the model list tables first and pull only the definitions it needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sharing schema but skipping read-only enforcement.&lt;/strong&gt; Grounding fixes accuracy, not authorization. If the underlying connection can write, a cleverly phrased request (or a prompt-injection in your data) can still do damage. Enforce SELECT-only at the database role, not just by asking nicely in a system prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assuming grounded means correct.&lt;/strong&gt; A model that can see your schema will stop inventing columns, but it can still get &lt;em&gt;joins&lt;/em&gt; or &lt;em&gt;business logic&lt;/em&gt; wrong — using &lt;code&gt;created_at&lt;/code&gt; when you meant &lt;code&gt;activated_at&lt;/code&gt;. Always eyeball the generated SQL before it hits production, especially for anything aggregated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Letting the schema go stale.&lt;/strong&gt; If your connector caches the schema, a migration can put you right back to hallucinated columns. Make sure schema reads reflect the live structure, or refresh the cache on deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leaking data through the schema itself.&lt;/strong&gt; Column names like &lt;code&gt;patient_ssn&lt;/code&gt; or comments in your DDL can be sensitive. Share the structure you want the AI to see, not necessarily every internal table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;AI invents columns because it's guessing without your schema. The fix is to ground it in your real structure — but you can do that without ever handing over your credentials. Split the two: the model needs the &lt;strong&gt;map&lt;/strong&gt; (tables, columns, types, keys) to write correct SQL; it does not need the &lt;strong&gt;keys&lt;/strong&gt; (passwords, write access, raw network reach) to do its job. A connector or MCP-style broker delivers the schema automatically, keeps it current, runs queries read-only, and holds the credentials so the AI never sees them. You end up with SQL that actually runs and a database that's harder to hurt.&lt;/p&gt;

&lt;p&gt;Have you connected an AI assistant to your database yet? What broke first — the hallucinated columns, or the security review? Drop your setup (and your favorite "there is no such column" story) in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources and further reading: &lt;a href="https://www.anthropic.com/news/model-context-protocol" rel="noopener noreferrer"&gt;Introducing the Model Context Protocol (Anthropic)&lt;/a&gt;, &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18" rel="noopener noreferrer"&gt;MCP Specification&lt;/a&gt;, &lt;a href="https://www.getwren.ai/post/reducing-hallucinations-in-text-to-sql-building-trust-and-accuracy-in-data-access" rel="noopener noreferrer"&gt;Reducing Hallucinations in Text-to-SQL (Wren AI)&lt;/a&gt;, &lt;a href="https://pub.towardsai.net/improving-text-to-sql-accuracy-with-schema-aware-reasoning-528eadfdc99b" rel="noopener noreferrer"&gt;Improving Text-to-SQL Accuracy with Schema-Aware Reasoning&lt;/a&gt;, &lt;a href="https://techcommunity.microsoft.com/blog/azuredevcommunityblog/building-mcp-servers-for-your-database-flexibility-safety-and-tradeoffs/4546385" rel="noopener noreferrer"&gt;Building MCP servers for your database (Microsoft)&lt;/a&gt;. If you want a managed, read-only MCP server that implements this pattern, see the &lt;a href="https://docs.draxlr.com/docs/mcp-server" rel="noopener noreferrer"&gt;Draxlr MCP docs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Let Non-Technical Teammates Query the Database — Without Handing Out DB Access</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Sat, 05 Sep 2026 09:53:23 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/let-non-technical-teammates-query-the-database-without-handing-out-db-access-301p</link>
      <guid>https://dev.to/vivekdraxlr/let-non-technical-teammates-query-the-database-without-handing-out-db-access-301p</guid>
      <description>&lt;p&gt;You know the messages. "Quick one — how many trial users converted last month?" "Can you pull the top 20 accounts by revenue?" "What's our churn for the enterprise plan?" Each one is a two-minute query for you, and a half-day wait for the person asking. Multiply that across a sales team, a support team, and a couple of founders, and you've quietly become a human query API.&lt;/p&gt;

&lt;p&gt;There are two obvious ways out of this, and both are traps. You can give people direct database access — hand out a read-only connection string and a SQL editor and tell them to help themselves. Now credentials for your production data are sitting in six laptops, three password managers, and at least one Slack DM, and you have no idea who ran what. Or you can keep the ticket queue and stay the bottleneck. Neither is good. This post is about a third option: putting an AI assistant in front of the database so people can ask questions in plain English, while the actual credentials, the read/write boundary, and the row-level scoping all stay firmly under your control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with the two easy answers
&lt;/h2&gt;

&lt;p&gt;Self-serve analytics has been promised for a decade and mostly hasn't landed. When teams lock everything down, business users go right back to filing tickets and the data team is the bottleneck again. When teams open everything up, adoption soars but trust collapses — the same metric gets defined three different ways and nobody knows which "active users" number is real. And underneath both failure modes is a security question people gloss over: &lt;em&gt;who actually holds the keys to the database?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Handing raw credentials to non-technical teammates fails on every axis. Credentials leak. They're long-lived and painful to rotate. A well-meaning person can run a monster query that locks a table on your primary during peak traffic. And you get no audit trail worth the name. What you actually want is for people to get answers without ever touching a credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third option: an AI assistant behind a broker
&lt;/h2&gt;

&lt;p&gt;The pattern that makes this safe is a broker (increasingly, a Model Context Protocol server) that sits between the AI tool and your database. The person talks to an AI assistant — Claude, Cursor, ChatGPT, whatever they already use. The assistant doesn't connect to your database. It connects to the broker. The broker holds the real connection and enforces the rules.&lt;/p&gt;

&lt;p&gt;Here's the important part: &lt;strong&gt;the AI never learns your database credentials.&lt;/strong&gt; It sends a request like "run this SELECT" to the broker, and the broker — which authenticates the &lt;em&gt;user&lt;/em&gt;, not the model — decides whether to run it and with what privileges. Connecting the AI tool looks roughly like this on the client side, with no connection string in sight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&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;"company-db"&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://your-broker.example.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"transport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth"&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;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;The teammate signs in through OAuth. No password gets pasted into a prompt, no secret ends up in a chat log, and access can be revoked centrally the moment someone leaves. That single property — credentials live in one place, not scattered across every laptop — is most of the security win right there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "safe" actually means here
&lt;/h2&gt;

&lt;p&gt;A broker in the middle only helps if it enforces real guardrails. There are four worth insisting on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only by design.&lt;/strong&gt; The connection the broker uses should be a database role that can &lt;code&gt;SELECT&lt;/code&gt; and nothing else. In Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;CONNECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- new tables should inherit the same restriction&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
    &lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now if the assistant ever generates a write — because a prompt was sloppy or someone got clever — the database itself rejects it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- assistant attempts this on the read-only connection:&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;last_login&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 year'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ERROR: permission denied for table users&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI can explore all day and never modify a byte. That's not a promise you're trusting the model to keep; it's enforced one layer below it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row-level security scopes what each person sees.&lt;/strong&gt; Read-only isn't the same as "everyone sees everything." If your support lead should only see EU orders, row-level security enforces that regardless of what SQL the AI writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;team_sees_own_region&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_region'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broker sets &lt;code&gt;app.current_region&lt;/code&gt; from the authenticated user's identity, so the same natural-language question returns different rows for different people, and nobody can widen their own scope by rephrasing the prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema awareness keeps the SQL honest.&lt;/strong&gt; The broker can share your schema — table names, columns, types — with the assistant. This is the difference between an AI that guesses at a &lt;code&gt;customer_name&lt;/code&gt; column that doesn't exist and one that writes &lt;code&gt;SELECT full_name FROM users&lt;/code&gt; because it can see the real shape of your data. Sharing schema (not credentials) is what turns text-to-SQL from a party trick into something you'd let a colleague rely on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything is logged.&lt;/strong&gt; Because every query flows through one gateway, you get a single audit trail: who asked, what SQL ran, when. Compare that to auditing six SQL editors on six laptops — which is to say, not auditing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in practice
&lt;/h2&gt;

&lt;p&gt;Once this is wired up, a teammate who has never written SQL just asks. Here are realistic questions and the SQL a schema-aware assistant produces behind the scenes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;What they type&lt;/th&gt;
&lt;th&gt;What runs (they never see this)&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;"How many trials converted last week?"&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;SELECT count(*) FROM subscriptions WHERE plan &amp;lt;&amp;gt; 'trial' AND status = 'active' AND converted_at &amp;gt;= now() - interval '7 days';&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;"Top 5 accounts by revenue this quarter"&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;SELECT a.name, sum(o.amount) AS revenue FROM orders o JOIN accounts a ON a.id = o.account_id WHERE o.created_at &amp;gt;= date_trunc('quarter', now()) GROUP BY a.name ORDER BY revenue DESC LIMIT 5;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;"Which features did churned users touch least?"&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;SELECT e.feature, count(*) FROM events e JOIN users u ON u.id = e.user_id WHERE u.churned = true GROUP BY e.feature ORDER BY count(*) ASC;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A slightly meatier one — weekly signups versus conversions — shows the assistant handling a join and a couple of filtered aggregates the person would never want to write by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'week'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;signup_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
                     &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'trial'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;converted&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'8 weeks'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The teammate gets a table back. You never got pinged. And nobody touched a credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes to avoid
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pointing the broker at your primary.&lt;/strong&gt; Even read-only queries consume CPU and can cause lock contention. Point the read-only role at a replica so a curious sales rep's 10-million-row scan can't slow down checkout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exposing the entire schema on day one.&lt;/strong&gt; Start restrictive. Grant &lt;code&gt;SELECT&lt;/code&gt; on the ten tables people actually ask about, not all forty including &lt;code&gt;password_resets&lt;/code&gt; and &lt;code&gt;internal_audit&lt;/code&gt;. Expand based on real questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skipping the semantic layer.&lt;/strong&gt; If "active user" means three different things in three tables, the AI will pick one and sound confident. Define your core metrics once — in views or a semantic layer — so everyone's "active users" resolves to the same SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting output without spot-checks.&lt;/strong&gt; AI-generated SQL is usually right and occasionally, fluently wrong. For any number that drives a decision, have someone technical eyeball the generated query the first few times a new question type shows up. The read-only boundary protects your data; it doesn't guarantee the logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assuming read-only means private.&lt;/strong&gt; Read-only still returns whatever the role can see. If people shouldn't see salaries or other customers' data, that's row-level and column-level security's job — not something "read-only" covers on its own.&lt;/p&gt;

&lt;p&gt;If you'd rather not stand up and maintain the broker yourself, managed MCP servers implement these patterns for you — &lt;a href="https://docs.draxlr.com/docs/mcp-server" rel="noopener noreferrer"&gt;Draxlr&lt;/a&gt;, for instance, runs one that connects over OAuth and is read-only (SELECT only), so the AI can list schema, run queries, and build dashboards without ever holding your credentials. The point stands whichever route you take: the credentials, the read/write boundary, and the scoping belong to you, not to the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;The goal isn't to teach your whole company SQL, and it definitely isn't to hand out database passwords. It's to let people ask questions in the language they already speak while the guardrails stay yours. Put a broker between the AI and the database so the model never sees credentials. Make the connection read-only at the database level, not by asking nicely. Scope rows per person with RLS. Share schema so the SQL is accurate. Log everything through one gateway. Do that, and the "quick one — can you pull this?" messages mostly stop, without you trading away a single ounce of control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;How does your team handle data requests today — ticket queue, shared read-only login, a BI tool, or something AI-driven? If you've let non-technical teammates query your database, what guardrail turned out to matter most, and what surprised you? Drop it in the comments.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Let Your SaaS Customers Ask Their Data Questions — Without Handing Over the Keys</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Mon, 31 Aug 2026 04:10:28 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/let-your-saas-customers-ask-their-data-questions-without-handing-over-the-keys-3965</link>
      <guid>https://dev.to/vivekdraxlr/let-your-saas-customers-ask-their-data-questions-without-handing-over-the-keys-3965</guid>
      <description>&lt;p&gt;Every SaaS product eventually collects a data request it didn't plan for. A customer emails: "Can you tell me how many active seats we used each month last quarter?" Your dashboard shows totals, not that exact cut. So an engineer writes a one-off query, pastes the result into a reply, and moves on — until the next customer asks something slightly different.&lt;/p&gt;

&lt;p&gt;Dashboards can only anticipate so many questions. The dream is to let customers ask in plain English — "what was our churn last month?", "which of my projects has the most open tickets?" — and get an answer straight from the data you already store on their behalf. The scary part is obvious: you'd be pointing an AI at a shared, multi-tenant production database where one wrong &lt;code&gt;WHERE&lt;/code&gt; clause leaks Customer A's numbers to Customer B.&lt;/p&gt;

&lt;p&gt;The good news is that this is a solved problem, and it doesn't require trusting the AI to get filtering right. It requires two layers that have nothing to do with the AI at all: &lt;strong&gt;row-level security&lt;/strong&gt; in the database, and a &lt;strong&gt;broker&lt;/strong&gt; (typically an MCP server) that scopes every connection to one tenant. Let the database enforce isolation and let the broker hold the credentials. The AI just writes SQL against a door that only opens onto one customer's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: one database, many customers
&lt;/h2&gt;

&lt;p&gt;Most B2B SaaS apps are multi-tenant — every customer's rows live in the same tables, tagged with a &lt;code&gt;tenant_id&lt;/code&gt; (or &lt;code&gt;account_id&lt;/code&gt;, &lt;code&gt;org_id&lt;/code&gt;, whatever you call it).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt;    &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;plan&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;seats&lt;/span&gt;        &lt;span class="nb"&gt;int&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;-- 'active', 'canceled', 'trialing'&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole game is making sure that no query — whether written by your app, an engineer, or an AI — can ever return rows where &lt;code&gt;tenant_id&lt;/code&gt; doesn't match the customer who's asking. Doing that in application code means remembering to add &lt;code&gt;AND tenant_id = $1&lt;/code&gt; to every single query, forever. Miss it once and you have a data breach. So push the rule down into the database instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: row-level security does the isolation
&lt;/h2&gt;

&lt;p&gt;PostgreSQL's row-level security (RLS) lets you attach a policy to a table so the database itself filters rows based on a runtime value — no matter what query arrives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Turn on RLS for the table&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Only return rows for the tenant in the current session context&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the tenant identity travels with the connection, not the query. You set it once per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Scope the value to THIS transaction only (critical for pooled connections)&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'8f3a...c2'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Returns only tenant 8f3a...c2's active subscriptions, always.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the query has no &lt;code&gt;tenant_id&lt;/code&gt; filter in it at all. The database added it. Even a careless &lt;code&gt;SELECT * FROM subscriptions&lt;/code&gt; returns only the current tenant's rows. As one Crunchy Data write-up puts it, there's "zero chance of forgetting a tenant filter" because Postgres won't allow a cross-tenant query in the first place.&lt;/p&gt;

&lt;p&gt;Two details that turn a demo into something production-safe:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SET LOCAL&lt;/code&gt;, not &lt;code&gt;SET&lt;/code&gt;. Connection poolers reuse physical connections across requests. &lt;code&gt;SET LOCAL&lt;/code&gt; binds the value to the current transaction so it can't leak into the next customer's request on the same connection. &lt;code&gt;SET&lt;/code&gt; persists for the whole session and will eventually hand one tenant another tenant's context.&lt;/p&gt;

&lt;p&gt;Index the tenant column first. RLS effectively prepends &lt;code&gt;tenant_id = ?&lt;/code&gt; to every scan. Without &lt;code&gt;tenant_id&lt;/code&gt; as the leading column of your indexes, those scans get dramatically slower — often two orders of magnitude on large tables.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_subs_tenant_status&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Layer 2: a broker gives customers a door — not your credentials
&lt;/h2&gt;

&lt;p&gt;RLS handles isolation. But how does a customer's AI assistant actually reach the database? You are &lt;em&gt;not&lt;/em&gt; going to email them a connection string. This is where a broker sits in the middle — increasingly, one that speaks the Model Context Protocol (MCP), an open standard for connecting AI assistants to tools and data.&lt;/p&gt;

&lt;p&gt;The pattern looks like this. Each customer authenticates to the broker over OAuth and gets a token that encodes &lt;em&gt;their&lt;/em&gt; &lt;code&gt;tenant_id&lt;/code&gt;. When their AI client wants to run a query, it sends the request to the broker, which:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validates the customer's OAuth token and extracts the &lt;code&gt;tenant_id&lt;/code&gt; from it.&lt;/li&gt;
&lt;li&gt;Opens a database connection using &lt;strong&gt;your&lt;/strong&gt; credentials (the AI never sees them).&lt;/li&gt;
&lt;li&gt;Runs &lt;code&gt;SET LOCAL app.current_tenant = &amp;lt;tenant_id from the token&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Executes the AI's SQL — now automatically filtered by RLS — and returns just the rows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The properties that fall out of this are exactly what you want for a customer-facing feature:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;How the two layers handle it&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Credential exposure&lt;/td&gt;
&lt;td&gt;The broker holds the DB connection. The AI model and client never see raw credentials, tokens, or secrets.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Cross-tenant leaks&lt;/td&gt;
&lt;td&gt;RLS filters every query by &lt;code&gt;tenant_id&lt;/code&gt; at the database level, even if the AI writes a bad query.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Accidental writes&lt;/td&gt;
&lt;td&gt;Give the broker a read-only role — SELECT only, so an AI can explore but never modify data.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Revoking access&lt;/td&gt;
&lt;td&gt;OAuth tokens are centrally revocable; no long-lived secret lives in a prompt, chat log, or config file.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Auditability&lt;/td&gt;
&lt;td&gt;The broker logs the natural-language question, the generated SQL, and the result for every request.&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can build this broker yourself, or use a managed MCP server. &lt;a href="https://docs.draxlr.com/docs/mcp-server" rel="noopener noreferrer"&gt;Draxlr&lt;/a&gt;, for example, offers one that connects over OAuth and is read-only by design — one implementation of the pattern above — but the architecture matters more than any product: RLS in the database, a credential-holding broker in front of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together: a request's journey
&lt;/h2&gt;

&lt;p&gt;Say a customer opens their AI assistant and types:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How many active seats did we have at the end of last month?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The AI, given only your schema (not your data), writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;active_seats&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broker sets &lt;code&gt;app.current_tenant&lt;/code&gt; from the customer's token and runs it. RLS scopes the sum to that tenant. The result comes back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;&lt;th&gt;active_seats&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;&lt;tr&gt;&lt;td&gt;142&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The customer sees &lt;code&gt;142&lt;/code&gt; — their number, and only their number. They never learned SQL, never got a database login, and couldn't have reached another tenant's row if they'd tried. The same door works whether they ask from Claude, Cursor, ChatGPT, or an in-app chat box, because the isolation lives in the database, not the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Table owners bypass RLS by default.&lt;/strong&gt; In Postgres, the role that owns a table isn't subject to its own RLS policies unless you force it. If your broker connects as the table owner, isolation silently does nothing. Add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Superuser and &lt;code&gt;BYPASSRLS&lt;/code&gt; roles ignore policies entirely.&lt;/strong&gt; Never let the broker connect as a superuser or a role with &lt;code&gt;BYPASSRLS&lt;/code&gt;. Give it a dedicated, low-privilege, read-only role.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting policies on new tables.&lt;/strong&gt; RLS is per-table and off by default. Add a new table, forget the policy, and it's wide open to every tenant. Make "enable RLS + add tenant policy" part of your migration checklist, or write a test that fails when a tenant-scoped table lacks a policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SET&lt;/code&gt; instead of &lt;code&gt;SET LOCAL&lt;/code&gt; on pooled connections.&lt;/strong&gt; Worth repeating because it's the subtlest leak: a persistent &lt;code&gt;SET&lt;/code&gt; outlives the request and the next tenant inherits it. Always scope to the transaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting the AI to add the filter.&lt;/strong&gt; The AI should never be your isolation boundary. If your only protection is "the prompt tells it to filter by tenant," you have no protection. RLS is what makes the AI's mistakes harmless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No composite index on &lt;code&gt;tenant_id&lt;/code&gt;.&lt;/strong&gt; Correct but slow is still a bug when a customer is waiting on an answer. Lead your indexes with the tenant column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Letting customers ask their data questions in plain English is no longer exotic — but the safety comes from architecture, not from trusting the model. Enforce isolation in the database with row-level security so no query, human or AI, can cross tenants. Put a broker in front that holds your credentials, scopes each connection to one tenant via OAuth, and stays read-only. Then the AI is just a convenient way to write SELECT statements against a door that only ever opens onto one customer's data. Index your tenant column, force RLS on owned tables, keep the broker's role low-privilege, and audit every generated query.&lt;/p&gt;

&lt;p&gt;Do that, and "can you pull this number for us?" stops being a support ticket and starts being something your customers answer themselves — in seconds, safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Are you exposing data back to your customers yet — through dashboards, an API, or natural language? If you've built multi-tenant RLS in production, I'd love to hear what bit you: the pooling gotcha, the owner-bypass surprise, or something else entirely. Drop it in the comments.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>postgres</category>
    </item>
    <item>
      <title>How to Actually Measure Whether Your Text-to-SQL Is Any Good</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Fri, 28 Aug 2026 16:31:44 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/how-to-actually-measure-whether-your-text-to-sql-is-any-good-4bhe</link>
      <guid>https://dev.to/vivekdraxlr/how-to-actually-measure-whether-your-text-to-sql-is-any-good-4bhe</guid>
      <description>&lt;p&gt;You wired an LLM up to your database. You asked it "how many active users signed up last month?", it wrote a tidy &lt;code&gt;SELECT&lt;/code&gt;, the number looked plausible, and everyone in the demo nodded. Ship it.&lt;/p&gt;

&lt;p&gt;Then a founder asks the same question a slightly different way, gets a number that's off by 20%, and now nobody trusts the feature. The uncomfortable truth about text-to-SQL is that a query that &lt;em&gt;runs&lt;/em&gt; tells you almost nothing about whether it's &lt;em&gt;right&lt;/em&gt;. "No error" and "correct answer" are two completely different things, and the gap between them is where trust quietly dies.&lt;/p&gt;

&lt;p&gt;If you're building a natural-language query feature — an internal analytics box, a customer-facing "ask your data" panel, an AI assistant hooked to production — you need a way to measure accuracy that goes beyond "it looked fine when I tried it." This post walks through how text-to-SQL is actually evaluated, the sharp edges in those metrics, what the famous benchmarks do and don't prove, and how to build a lightweight eval suite for your own schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  "It ran" is the weakest possible signal
&lt;/h2&gt;

&lt;p&gt;Consider a question against a typical SaaS schema:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How much revenue did we make from paid plans in July?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here are two queries an AI might produce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Query A&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-07-01'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="s1"&gt;'2026-08-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Query B&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-07-01'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="s1"&gt;'2026-08-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both run. Both return a single number. On a test database where every July invoice happens to be &lt;code&gt;paid&lt;/code&gt;, both return the &lt;em&gt;same&lt;/em&gt; number. Query B is wrong — it silently includes refunded and failed invoices — but nothing about the execution surfaces that. This is the core problem: to evaluate text-to-SQL you have to compare against a notion of &lt;em&gt;correct&lt;/em&gt;, not just &lt;em&gt;runnable&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways people measure accuracy
&lt;/h2&gt;

&lt;p&gt;There are three broad approaches, and they trade off strictness against fairness.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Weakness&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Exact-set match (ESM)&lt;/td&gt;
&lt;td&gt;Compares the SQL text/clauses against a reference query, component by component&lt;/td&gt;
&lt;td&gt;Punishes correct queries that are written differently&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Execution accuracy (EX)&lt;/td&gt;
&lt;td&gt;Runs both queries and compares the result sets&lt;/td&gt;
&lt;td&gt;Two different queries can return the same rows by accident&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Semantic equivalence&lt;/td&gt;
&lt;td&gt;Judges whether two queries mean the same thing (often via an LLM or query analysis)&lt;/td&gt;
&lt;td&gt;Harder to automate, can itself be wrong&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Exact-set match&lt;/strong&gt; is the oldest and the most brittle. Reference SQL says &lt;code&gt;WHERE status = 'paid'&lt;/code&gt;; the model writes &lt;code&gt;WHERE status IN ('paid')&lt;/code&gt;. Identical meaning, "wrong" by string comparison. ESM's rigid matching overlooks semantically correct but stylistically different queries, so it systematically &lt;em&gt;under&lt;/em&gt;-counts good answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Execution accuracy&lt;/strong&gt; fixed the obvious flaw: instead of comparing text, run both queries and compare the results. It has become the dominant metric in modern benchmarks precisely because it treats syntactically distinct but semantically equivalent queries as equal. If two queries produce the same rows, who cares how they're written?&lt;/p&gt;

&lt;p&gt;The catch is that execution equality does not imply semantic equivalence — which brings us to the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: execution accuracy lies on small test data
&lt;/h2&gt;

&lt;p&gt;Go back to Query A and Query B above. On a sparse test database, they agree. That's a &lt;strong&gt;false positive&lt;/strong&gt;: a wrong query scored as correct because the test data wasn't diverse enough to expose the difference. An incomplete &lt;code&gt;WHERE&lt;/code&gt; clause slips through whenever every row in the test set happens to satisfy the missing condition.&lt;/p&gt;

&lt;p&gt;This isn't a rare edge case. Studies of execution-based evaluation have measured false-positive rates around 11% — roughly one in nine "correct" queries is actually wrong and just got lucky on the test data. If your eval database is a handful of tidy demo rows, that number is worse, not better.&lt;/p&gt;

&lt;p&gt;The fix that the research community landed on is elegant: &lt;strong&gt;test-suite evaluation&lt;/strong&gt;. Instead of one small database, you evaluate the query against several databases specifically constructed so that a wrong query is very likely to diverge from the right one on at least one of them. The idea (from &lt;em&gt;Semantic Evaluation for Text-to-SQL with Distilled Test Suites&lt;/em&gt;) is to distill a compact set of databases that achieves high code coverage of the reference query, giving a tight approximation of true semantic accuracy without needing to prove equivalence formally.&lt;/p&gt;

&lt;p&gt;You can apply the spirit of this cheaply. Seed your test data with rows that &lt;em&gt;would&lt;/em&gt; break a lazy query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Adversarial seed rows for the revenue example&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s1"&gt;'2026-07-15'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;-- should count&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'refunded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-07-16'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;-- must NOT count&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'2026-07-17'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- must NOT count&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Query A returns 100 and Query B returns 300. The bug is visible. A good eval dataset is one where being sloppy &lt;em&gt;costs&lt;/em&gt; you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Spider and BIRD actually tell you
&lt;/h2&gt;

&lt;p&gt;Two public benchmarks dominate the conversation, and it's worth knowing what each is really measuring before you quote a leaderboard number to your team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spider&lt;/strong&gt; contains 10,181 questions over 5,693 unique queries across 200 databases spanning 138 domains. Its whole point is &lt;em&gt;cross-domain generalization&lt;/em&gt;: the test databases are unseen at training time, so a high score means the model can handle a schema it has never met. That maps well to "will this work on my customers' databases," which are all different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BIRD&lt;/strong&gt; is the more real-world sibling — over 12,000 queries across 95 databases in 37 professional domains — and it adds two things Spider mostly ignores. First, it rewards using &lt;em&gt;external knowledge&lt;/em&gt; (the messy business context real questions require). Second, it measures &lt;strong&gt;efficiency&lt;/strong&gt;, not just correctness, via a Valid Efficiency Score: a query that returns the right rows but does a full table scan where an indexed lookup would do scores lower than a fast, correct one.&lt;/p&gt;

&lt;p&gt;That efficiency dimension matters more than people expect. Two queries can both be "correct" and differ wildly in cost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Correct but expensive: function on the column kills the index&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-07-15'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Correct and cheap: sargable range keeps the index usable&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-07-15'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="s1"&gt;'2026-07-16'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both return the same rows. In a customer-facing feature, only one of them is acceptable at scale.&lt;/p&gt;

&lt;p&gt;The thing to remember: a leaderboard score is measured on &lt;em&gt;someone else's&lt;/em&gt; schemas and questions. It's a useful signal for picking a model, but it is not a substitute for testing against &lt;em&gt;your&lt;/em&gt; database and &lt;em&gt;your&lt;/em&gt; users' phrasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building your own eval suite
&lt;/h2&gt;

&lt;p&gt;The good news is you don't need a research pipeline. A useful eval is just a set of question → reference-query pairs (call them &lt;strong&gt;golden queries&lt;/strong&gt;) plus a harness that runs the model's output and compares result sets.&lt;/p&gt;

&lt;p&gt;Start by collecting real questions — from support tickets, from analysts' saved queries, from whatever people actually ask. For each, write the SQL &lt;em&gt;you&lt;/em&gt; know is correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- golden_queries.yaml (conceptually)&lt;/span&gt;
&lt;span class="c1"&gt;-- id: revenue_paid_july&lt;/span&gt;
&lt;span class="c1"&gt;--   question: "How much revenue from paid plans in July?"&lt;/span&gt;
&lt;span class="c1"&gt;--   sql: |&lt;/span&gt;
&lt;span class="c1"&gt;--     SELECT SUM(amount) FROM invoices&lt;/span&gt;
&lt;span class="c1"&gt;--     WHERE status = 'paid'&lt;/span&gt;
&lt;span class="c1"&gt;--       AND created_at &amp;gt;= '2026-07-01'&lt;/span&gt;
&lt;span class="c1"&gt;--       AND created_at &amp;lt; '2026-08-01';&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the harness, in pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;passed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;golden_queries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;predicted_sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;predicted_sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# your candidate
&lt;/span&gt;    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;SQLError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid_sql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="c1"&gt;# your golden answer
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;result_sets_equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;         &lt;span class="c1"&gt;# order-insensitive compare
&lt;/span&gt;        &lt;span class="n"&gt;passed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrong_result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Execution accuracy: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;passed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;golden_queries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details make this far more honest than a naive version. Compare result &lt;em&gt;sets&lt;/em&gt;, not row order, unless the question asked for a specific ordering — otherwise a correct &lt;code&gt;GROUP BY&lt;/code&gt; fails just because rows came back shuffled. Run every case against the &lt;strong&gt;adversarial&lt;/strong&gt; seed data from earlier so false positives can't hide. And bucket your failures — invalid SQL, wrong table, missing filter, wrong aggregation — because "62% accurate" is far less useful than "most failures are a missing tenant filter."&lt;/p&gt;

&lt;p&gt;If you'd rather not run the model against production directly during all this, a read-only gateway helps: managed MCP servers like &lt;a href="https://docs.draxlr.com/docs/mcp-server" rel="noopener noreferrer"&gt;Draxlr&lt;/a&gt; expose a database to an AI client over a read-only (SELECT-only) connection, which is a sane place to point an eval harness so a buggy generated query can't do anything but return wrong rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;The most common one is treating a green demo as evidence. A feature that answers five questions correctly on stage can be 60% accurate across the long tail of real phrasings, and you'll never know until you measure the tail.&lt;/p&gt;

&lt;p&gt;The second is a tiny, tidy eval database. Sparse data is exactly what manufactures false positives; if every row satisfies the filters your model forgets, your eval will happily bless broken SQL. Diverse, adversarial rows are a feature.&lt;/p&gt;

&lt;p&gt;The third is scoring only correctness and ignoring cost. A query that's right but scans a 200-million-row table is a production incident waiting to happen — track query time or plan cost alongside accuracy.&lt;/p&gt;

&lt;p&gt;Fourth: overfitting to the questions you happened to write. If your golden set is 30 questions and you tune prompts until all 30 pass, you've built a model that's great at those 30 questions — keep a held-out set you &lt;em&gt;don't&lt;/em&gt; tune against. And version the eval itself: when you change the prompt, schema description, or model, the number that matters is the &lt;em&gt;delta&lt;/em&gt; from a saved baseline, not the absolute score.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Evaluating text-to-SQL is about measuring correctness, not runnability. Exact-match under-counts good queries; execution accuracy is the practical standard but produces false positives on thin test data; test-suite-style adversarial data is how you close that gap. Public benchmarks like Spider (cross-domain generalization) and BIRD (real-world knowledge plus efficiency) are great for choosing a model but never a substitute for testing on your own schema. Build a golden-query set from real questions, run it against deliberately diverse data, compare result sets rather than SQL text, track cost as well as correctness, and re-run it on every change. That's the difference between "it worked in the demo" and "we know it's 94% accurate and we watch that number."&lt;/p&gt;

&lt;p&gt;How are you measuring your text-to-SQL feature today — golden queries, LLM-as-judge, eyeballing, or nothing yet? Drop your setup (and your favorite false-positive horror story) in the comments.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>From Plain English to a Live Dashboard: Automating Reporting with MCP</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Thu, 27 Aug 2026 04:00:23 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/from-plain-english-to-a-live-dashboard-automating-reporting-with-mcp-5dl7</link>
      <guid>https://dev.to/vivekdraxlr/from-plain-english-to-a-live-dashboard-automating-reporting-with-mcp-5dl7</guid>
      <description>&lt;p&gt;Someone in your Slack asks, "How many people signed up last week?" You open a SQL client, write a query you've written a dozen times before, run it, copy the number, and paste it back. A week later, the same question lands in a different channel. Same query, same copy-paste, same five minutes gone.&lt;/p&gt;

&lt;p&gt;Multiply that by every "quick number" your team asks for — signups, active users, MRR, refunds, top accounts — and reporting quietly becomes a part-time job that nobody signed up for. The knowledge lives in your head and in a folder of &lt;code&gt;.sql&lt;/code&gt; files nobody else can find.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Model Context Protocol (MCP)&lt;/strong&gt; offers a cleaner path. It's an open standard for connecting AI assistants to external systems — including your database — through a consistent, permissioned interface. Instead of the AI guessing at a connection or you pasting credentials into a chat box, an MCP server sits in between and exposes a small set of safe operations: read the schema, run a read-only query, save it, add it to a dashboard. This article walks through that whole loop — plain-English question to living report — and where it can go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually sits between a question and a chart
&lt;/h2&gt;

&lt;p&gt;MCP servers expose three kinds of capabilities, and it helps to know which is doing what (&lt;a href="https://www.speakeasy.com/mcp/core-concepts/" rel="noopener noreferrer"&gt;Speakeasy has a clear breakdown&lt;/a&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;In a database context&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Resources&lt;/td&gt;
&lt;td&gt;Read-only data the model can pull in for context&lt;/td&gt;
&lt;td&gt;Your table and column definitions — the schema&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Tools&lt;/td&gt;
&lt;td&gt;Actions the model can invoke&lt;/td&gt;
&lt;td&gt;Run a query, save a query, add it to a dashboard&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Prompts&lt;/td&gt;
&lt;td&gt;Reusable templates for common workflows&lt;/td&gt;
&lt;td&gt;"Build a weekly signups report" as a repeatable recipe&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important part: the AI never touches your database directly. It asks the server for the schema, drafts SQL, and asks the server to run it. Because the query tool is &lt;strong&gt;read-only by design&lt;/strong&gt;, a &lt;code&gt;DELETE&lt;/code&gt; or &lt;code&gt;DROP&lt;/code&gt; the model dreams up simply gets rejected. The AI explores freely; your data stays intact.&lt;/p&gt;

&lt;p&gt;This is also why schema-awareness matters so much. When the model can read your actual tables and columns as a resource, it stops inventing plausible-but-wrong names like &lt;code&gt;user.signup_date&lt;/code&gt; when your column is really &lt;code&gt;users.created_at&lt;/code&gt;. Grounding the model in the real schema is the single biggest thing that keeps generated SQL honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Ask in plain English
&lt;/h2&gt;

&lt;p&gt;Here's the shape of an interaction. Assume a typical SaaS database with &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;subscriptions&lt;/code&gt;, and &lt;code&gt;events&lt;/code&gt; tables. You type:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How many users signed up in the last 7 days, grouped by day?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The assistant fetches the schema, sees &lt;code&gt;users(id, email, created_at, plan)&lt;/code&gt;, and produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;signup_day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;signups&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;signup_day&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You didn't specify the column name, the date function, or the grouping. The model inferred them from the schema. That's the difference between an AI that's guessing and one that's reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Verify before you trust
&lt;/h2&gt;

&lt;p&gt;The generated SQL is a draft, not gospel. The advantage of the read-only setup is that running it to check is completely safe. The result comes back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;signup_day&lt;/th&gt;
&lt;th&gt;signups&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;2026-08-20&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-08-21&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-08-22&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-08-23&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-08-24&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-08-25&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-08-26&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Glance at it. Do the weekends dip the way they usually do? Is the total in the right ballpark? A thirty-second sanity check here saves you from confidently reporting a number that's off because "signup" quietly meant "row created, including invited-but-not-activated users." More on that trap below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Save the query so it's never rewritten
&lt;/h2&gt;

&lt;p&gt;This is the step that breaks the treadmill. Once the query is correct, save it with a name and description through the server's save tool:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Save that as &lt;strong&gt;Weekly Signups by Day&lt;/strong&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now it's a named, reusable report. Next week nobody rewrites it — they ask to run &lt;em&gt;Weekly Signups by Day&lt;/em&gt; and get fresh numbers against live data. You've turned a throwaway query into an asset the whole team can call by name. This is also where a &lt;strong&gt;prompt&lt;/strong&gt; template earns its keep: "produce a signups report for the last N days" becomes a recipe you invoke, not SQL you retype.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Pin it to a dashboard
&lt;/h2&gt;

&lt;p&gt;The last move is to make the report ambient so people stop asking at all. Add the saved query as a dashboard tile:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Add &lt;strong&gt;Weekly Signups by Day&lt;/strong&gt; to the &lt;strong&gt;Growth&lt;/strong&gt; dashboard as a bar chart."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Chain a few of these together and you've assembled a real reporting surface — signups, activation rate, MRR, churn — entirely from plain-English requests, each one a verified, saved, named query underneath. Managed MCP servers implement exactly this loop; &lt;a href="https://docs.draxlr.com/docs/mcp-server" rel="noopener noreferrer"&gt;Draxlr's&lt;/a&gt; is one example that connects over OAuth and is read-only (SELECT only), with tools to list databases, fetch schema, run and save queries, and build dashboards. The pattern is the same regardless of which server you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fuller example: three reports, one conversation
&lt;/h2&gt;

&lt;p&gt;Say you want a small revenue snapshot. You ask for three things in a row.&lt;/p&gt;

&lt;p&gt;New MRR from subscriptions started this month:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;monthly_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;new_mrr&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Top 5 plans by active subscribers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;subscribers&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;subscribers&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refunds issued in the last 30 days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;refunded_total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'refund'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save all three, drop them on a "Revenue Health" dashboard, and you've built in five minutes what used to be a recurring manual chore. The AI wrote the SQL; you supplied the judgment about what's worth measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Treating the first answer as the final answer.&lt;/strong&gt; LLMs can produce SQL that runs cleanly but answers a subtly different question than you asked. Always read the query and eyeball the result before you save it or share the number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fuzzy metric definitions.&lt;/strong&gt; The word "active" can mean logged-in-this-week, has-a-paid-plan, or has-any-event-ever. If your team hasn't agreed on definitions, the AI will pick one for you — and it may not be the one your board is using. Industry write-ups on AI reporting consistently flag &lt;a href="https://improvado.io/blog/ai-report-generation" rel="noopener noreferrer"&gt;ungoverned metric definitions&lt;/a&gt; as the top source of "hallucinated" analytics: the number is real, but the definition behind it is wrong. Where you can, point the model at a governed view or a semantic layer rather than raw tables, so "revenue" means one thing everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Granting more access than you need.&lt;/strong&gt; The whole security benefit collapses if you connect the AI with a read-write account. Use a read-only role, and prefer a setup where the server enforces SELECT-only regardless of the credential. The AI should be able to &lt;em&gt;read&lt;/em&gt; everything it's allowed to and &lt;em&gt;change&lt;/em&gt; nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No audit trail.&lt;/strong&gt; If you can't later see which queries the AI ran, you can't debug a wrong number or satisfy a compliance question. Favor an approach where access is centralized and queries are logged, not scattered across personal database clients.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skipping the schema step.&lt;/strong&gt; If the model isn't given the real schema, it falls back to guessing table and column names. That's where hallucinated columns come from. Schema-first, always.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;The reporting treadmill isn't a SQL problem — it's a &lt;em&gt;reuse&lt;/em&gt; problem. You already know how to write the query; the pain is writing it again and again and keeping it somewhere findable. MCP addresses that by putting a safe, schema-aware, read-only interface between the AI and your database, then letting you promote good queries into saved reports and dashboard tiles.&lt;/p&gt;

&lt;p&gt;The loop is small and repeatable: ask in plain English, verify the generated SQL against live data, save the query with a clear name, and pin it to a dashboard. Keep humans in the verification seat, nail down your metric definitions, and never hand the AI more than read access. Do that and "quick number" requests stop interrupting your day — they answer themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;How does your team handle recurring reporting today — a folder of saved queries, a BI tool, or a lot of copy-paste? If you've wired an AI assistant to your database through MCP, I'd love to hear what worked and what surprised you. Drop a comment with the setup you're using.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://www.speakeasy.com/mcp/core-concepts/" rel="noopener noreferrer"&gt;MCP core concepts — Speakeasy&lt;/a&gt;, &lt;a href="https://motherduck.com/blog/what-is-mcp-guide-agentic-analytics/" rel="noopener noreferrer"&gt;What is MCP? A Data Person's Guide to Agentic Analytics — MotherDuck&lt;/a&gt;, &lt;a href="https://improvado.io/blog/ai-report-generation" rel="noopener noreferrer"&gt;AI Report Generation guide — Improvado&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Who Just Queried Prod? Auditing and Controlling AI Database Access Across a Team</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Wed, 26 Aug 2026 08:43:01 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/who-just-queried-prod-auditing-and-controlling-ai-database-access-across-a-team-4b24</link>
      <guid>https://dev.to/vivekdraxlr/who-just-queried-prod-auditing-and-controlling-ai-database-access-across-a-team-4b24</guid>
      <description>&lt;p&gt;Six months ago, exactly one person on your team could query the production database from an AI tool. Today it's everyone. Someone pasted a connection string into an AI assistant, it worked beautifully, and the pattern spread. Now three engineers, a product manager, and a support lead all ask a chatbot questions that quietly turn into &lt;code&gt;SELECT&lt;/code&gt; statements against live data.&lt;/p&gt;

&lt;p&gt;That's genuinely useful. It's also a governance blind spot. If someone asks "why did this customer's numbers look weird last Tuesday," can you answer &lt;em&gt;who&lt;/em&gt; — or &lt;em&gt;what&lt;/em&gt; — ran the query that touched their records? With scattered connection strings and shared credentials, the honest answer is usually "no idea."&lt;/p&gt;

&lt;p&gt;This article is about closing that gap: how to give a team AI-powered database access while keeping it identifiable, scoped, and auditable. The goal isn't to slow anyone down. It's to make sure that when access grows from one person to twenty, you still know what's happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode: shared secrets and no paper trail
&lt;/h2&gt;

&lt;p&gt;The default way people connect an AI tool to a database is to hand it a connection string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgresql://app_user:s3cr3t@db.internal:5432/production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do that across a team and you inherit four problems at once. Everyone shares one database identity, so every query looks identical in the logs. The secret lives in prompts, chat histories, config files, and screenshots. Rotating it means chasing down every place it was pasted. And the database has no idea whether a human or a model issued a given statement.&lt;/p&gt;

&lt;p&gt;The database's own audit log doesn't save you here, because from its point of view there's a single user, &lt;code&gt;app_user&lt;/code&gt;, doing everything. You've lost the two facts that matter most for governance: &lt;em&gt;which person&lt;/em&gt; the access belongs to, and &lt;em&gt;whether their tool was allowed to do what it did&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What good looks like: a broker between the AI and the database
&lt;/h2&gt;

&lt;p&gt;The pattern that fixes this is putting a broker — often an MCP (Model Context Protocol) server — between AI clients and the database. Instead of each tool holding raw credentials, they connect to one governed gateway that holds the connection and enforces the rules.&lt;/p&gt;

&lt;p&gt;Model Context Protocol is an open standard for connecting AI assistants to external systems through a consistent interface. For databases, an MCP server exposes a small set of operations (list tables, fetch schema, run a read-only query) and becomes the single place where identity, permissions, and logging live.&lt;/p&gt;

&lt;p&gt;Routing everyone through one gateway gives you five properties that scattered connection strings can't:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;What it means in practice&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Per-person identity&lt;/td&gt;
&lt;td&gt;Each teammate authenticates as themselves (usually via OAuth), so every query is attributable to a human.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Least privilege&lt;/td&gt;
&lt;td&gt;The broker can be read-only by design — &lt;code&gt;SELECT&lt;/code&gt; passes, &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DROP&lt;/code&gt; get rejected before they reach the DB.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Central revocation&lt;/td&gt;
&lt;td&gt;Off-boarding is one toggle. No long-lived secret to hunt down across chat logs.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Audit trail&lt;/td&gt;
&lt;td&gt;One chokepoint logs who ran what, against which database, and when.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;No shadow access&lt;/td&gt;
&lt;td&gt;The database isn't network-exposed to every laptop; one gateway is.&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Managed MCP servers like &lt;a href="https://docs.draxlr.com/docs/mcp-server" rel="noopener noreferrer"&gt;Draxlr's&lt;/a&gt; implement this shape — connected over OAuth, read-only, with the credentials held server-side — but the pattern matters more than any product. You can build the same thing in-house. What follows works either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the audit trail
&lt;/h2&gt;

&lt;p&gt;Whatever broker you use, the non-negotiable is a log of every query with enough context to answer "who did this and why." At minimum, capture the acting identity, the SQL, the target database, a timestamp, and a correlation ID that ties a query back to the conversation that triggered it.&lt;/p&gt;

&lt;p&gt;A simple audit table looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ai_query_audit&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;actor_email&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- the human, via OAuth&lt;/span&gt;
    &lt;span class="n"&gt;ai_client&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- claude, cursor, chatgpt...&lt;/span&gt;
    &lt;span class="n"&gt;database_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sql_text&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;row_count&lt;/span&gt;     &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- 'allowed' | 'rejected'&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;-- why rejected, if it was&lt;/span&gt;
    &lt;span class="n"&gt;correlation_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_audit_actor_time&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ai_query_audit&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broker writes one row per attempt — including the rejected ones, which are often the interesting ones. Now the earlier question has an answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Who touched customer 4821's data in the last week?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;actor_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ai_client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql_text&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;ai_query_audit&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;sql_text&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%customer_id = 4821%'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    actor_email      | ai_client | created_at          | status  | sql_text
---------------------+-----------+---------------------+---------+------------------------------------------
 dana@acme.com       | claude    | 2026-08-19 14:02:11 | allowed | SELECT * FROM orders WHERE customer_id...
 support@acme.com    | chatgpt   | 2026-08-18 09:41:55 | allowed | SELECT status, total FROM orders WHERE...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can build governance dashboards straight off this table — top queriers, rejected-write attempts over time, which tables get hit most, unusual after-hours activity. That's the observability layer security teams actually ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoping access per team
&lt;/h2&gt;

&lt;p&gt;Not everyone needs the same reach. Least privilege for AI access should be at least as sharp as the RBAC you give employees — arguably sharper, because a model will cheerfully try anything you let it. Two levers do most of the work: which databases an identity can see, and whether it's read-only.&lt;/p&gt;

&lt;p&gt;A policy config for a broker might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;support&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;databases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;production_readonly&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read-only&lt;/span&gt;
    &lt;span class="na"&gt;row_filter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;:user_region"&lt;/span&gt;   &lt;span class="c1"&gt;# row-level scoping&lt;/span&gt;
  &lt;span class="na"&gt;analytics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;databases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;production_readonly&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;events_warehouse&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read-only&lt;/span&gt;
  &lt;span class="na"&gt;engineering&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;databases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;production_readonly&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;staging&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice there's no &lt;code&gt;write&lt;/code&gt; mode anywhere. For an AI exploration workflow, that's usually correct: the model can read the world and help you understand it, but it cannot modify a single row. If a prompt ever produces &lt;code&gt;DELETE FROM subscriptions&lt;/code&gt;, the broker rejects it and logs the attempt rather than executing it.&lt;/p&gt;

&lt;p&gt;Row-level filters are what make this safe for customer-facing use, too. Bind a filter like &lt;code&gt;tenant_id = :current_tenant&lt;/code&gt; at the broker and a support agent asking "show me recent orders" only ever sees their own region's data — no matter how the model phrases the SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the workflow actually feels like
&lt;/h2&gt;

&lt;p&gt;Governance shouldn't be visible to the person doing the work. From their side it's still plain English:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;User:&lt;/strong&gt; How many trial accounts converted to paid last month, by plan?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The AI client fetches the schema through the broker (so it uses real column names instead of hallucinating them), writes SQL, and the broker runs it read-only under that user's identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;conversions&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt;   &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trial_started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trial_started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;conversions&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user gets their answer. Meanwhile, one row lands in &lt;code&gt;ai_query_audit&lt;/code&gt; tagged with their email, the client they used, and a correlation ID. Nobody typed a credential; nobody can later ask "wait, who ran that?" and come up empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Logging the query but not the identity.&lt;/strong&gt; A log full of anonymous SQL is barely better than none. The acting human's identity is the column that turns a log into an audit trail — capture it first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treating a shared service account as "the AI user."&lt;/strong&gt; If every teammate's AI traffic authenticates as one account, you've rebuilt the connection-string problem with extra steps. One identity per person (or per agent), tied to your IAM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-lived tokens in config files.&lt;/strong&gt; Permanent keys can't be revoked cleanly and tend to leak. Prefer OAuth grants that expire and can be killed centrally the moment someone leaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only in name only.&lt;/strong&gt; "We told people not to run writes" is not a control. Enforce it at the broker so a rejected &lt;code&gt;UPDATE&lt;/code&gt; is a logged event, not a trust exercise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring shadow MCP.&lt;/strong&gt; The moment a governed path exists, make it the &lt;em&gt;only&lt;/em&gt; path. If people can still point tools straight at the database, your audit log has holes exactly where the risky queries are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No retention or tamper-evidence on the log.&lt;/strong&gt; An audit trail someone can quietly edit isn't much of an audit trail. Ship logs somewhere append-only, with a retention window that matches your compliance needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Scattered connection strings give a team speed and take away accountability. You can keep the speed. Put a broker between AI clients and the database, make each person authenticate as themselves, keep access read-only and scoped by role, and log every attempt — allowed and rejected — with the identity attached. Whether you build that broker yourself or adopt a managed MCP server, the properties are the same: attributable, revocable, least-privilege, auditable.&lt;/p&gt;

&lt;p&gt;The test is simple. If someone asks "who queried prod at 2 a.m. and why," you should be able to answer in one &lt;code&gt;SELECT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How does your team handle this today — shared credentials, a homegrown proxy, or a managed gateway? And what do you actually log per query? I'd love to hear what's working (and what's bitten you) in the comments.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
