Analyzing the Query Store with an AI agent

Let Claude Code or Codex analyze the Query Store through sqlcmd, with a login that reads the Query Store but not the tables, or on a clone without data.

Co-written with Claude, who ran the lab tests and part of the research.

The Query Store is the query memory of a database. Since SQL Server 2016, it records the text of each captured query, its execution plans and its runtime statistics per time interval, inside the database itself. It is enabled by default on databases created from SQL Server 2022 onward. You see its state in the sys.database_query_store_options view of each database.

It is often the only lasting trace of the queries that ran on a server. Without an audit or an Extended Events session prepared in advance, all that remains is the plan cache, emptied at restart and at each eviction, and the Query Store. I use it to look for a regression, a query that changed plans, an unusual read of a sensitive table, or simply to find out whether a given query ran yesterday at 1:32 p.m.

I recently ran this search at a client’s site, by hand, with a team that wanted to know whether two tables had been read by unusual queries across eight databases. We had to filter out hundreds of Entity Framework queries, get around the LIKE wildcards, start over database by database, and we did not finish. This article describes the approach I now recommend: give the Query Store to an agent to read, without giving it the data. All commands were tested on SQL Server 2025 CU9 in a Linux container, with the PachaDataFormation demo database.

What the Query Store records

For each captured query, the Query Store records:

  • its text, in sys.query_store_query_text, as the application sent it, without the leading and trailing spaces and comments, and rewritten when the engine parameterized the query;
  • its plans, as XML, in sys.query_store_plan;
  • its runtime statistics per interval (60 minutes by default) in sys.query_store_runtime_stats: execution count, duration, CPU, reads, rows returned (avg_rowcount, max_rowcount), first and last execution.

It records neither the login, nor the client machine, nor the program name. You will know that a query read a table, at what time and how many rows it returned, not who ran it.

Three mechanisms decide what gets in.

The capture mode

In ALL mode, every query is captured. In AUTO mode, the default since SQL Server 2019 (ALL in 2016 and 2017), the Query Store ignores infrequent and cheap queries. The ALTER DATABASE SET page gives its thresholds: a query is captured as soon as it reaches 30 executions, 1 second of compile CPU or 100 ms of execution CPU, accumulated over one day. The page on managing the Query Store says instead that they are “internally determined”. The documentation adds that queries inside stored procedures, cursors and natively compiled queries are always captured. That holds for stored procedures, but since SQL Server 2022 a cursor is subject to the thresholds: the details are in the article on Query Store capture modes.

I checked these thresholds on the demo database, in AUTO mode, each ad hoc query run by an application login:

QueryCPU of the first execution (SET STATISTICS TIME)ExecutionsExecutions recorded
SELECT * FROM Contact.Contact (120,140 rows)127 ms22, from the first one
SELECT * FROM Contact.Adresse (128,183 rows)55 ms2120, from the second one
Entity Framework style parameterized query, one rowunder 1 ms5021, from the thirtieth one
SELECT Email FROM Contact.Contact WHERE ContactId = 42420 ms1none
25 slices of 5,000 identifiers of Contact.Contact (names, emails, phone numbers)not measured25none

The captures fall where the published thresholds put them. The row count plays no part: a full read of 128,000 rows run once stays under the threshold if it is cheap, and an extraction split into small ad hoc queries can leave no trace at all. Executions before the capture are not counted either: count_executions shows 21 for a query run 50 times.

The context database

A query is recorded in the Query Store of the database it runs in, not in the one of the database it reads. Run from the Ventes database, with the Ventes Query Store in ALL mode, the following query shows up in the Query Store of Ventes, and not in the one of PachaDataFormation:

SELECT * FROM PachaDataFormation.Contact.Adresse WHERE AdresseId = 4812;

It shows up rewritten by simple parameterization: (@1 smallint)SELECT * FROM [PachaDataFormation].[Contact].[Adresse] WHERE [AdresseId]=@1. Searching for Contact.Adresse does not find it, searching for Adresse does.

Run from master or tempdb, the same query is recorded nowhere, since the Query Store can be enabled on neither master nor tempdb. Yet any login can connect to these two databases. The search must therefore cover every database the login of interest can access, and it keeps a blind spot.

Retention

Data is kept 30 days by default (STALE_QUERY_THRESHOLD_DAYS), and size-based cleanup can purge it earlier when the Query Store gets close to MAX_STORAGE_SIZE_MB (1,000 MB by default since SQL Server 2019). Since the Query Store is stored in the database, a database backup preserves it: if you need to keep today’s state, back up.

A database whose Query Store is OFF captures nothing more. It keeps what it recorded before being disabled, and a database where it was never enabled contains nothing.

Searching by hand

The manual method starts from a text search, such as my find-query-by-text.sql script, with a filter on last_execution_time added. You then remove the application’s queries, recognizable when it goes through Entity Framework 6: @p0 parameters, [Extent1] alias (EF Core writes short aliases such as [c] and parameters such as @__id_0 instead).

In LIKE, square brackets define a character set: '%[Extent1]%' means “contains one of the characters E, x, t, e, n or 1”. The filter NOT LIKE '%[Extent1]%' therefore removes almost everything. The underscore is a wildcard too, standing for any single character. On the lab Query Store, which then held four queries, only one of them from Entity Framework:

USE PachaDataFormation;

SELECT
    COUNT(*) AS total,
    SUM(CASE WHEN query_sql_text NOT LIKE N'%[Extent1]%' THEN 1 ELSE 0 END) AS no_escape,
    SUM(CASE WHEN query_sql_text NOT LIKE N'%\[Extent1\]%' ESCAPE N'\' THEN 1 ELSE 0 END) AS with_escape,
    SUM(CASE WHEN CHARINDEX(N'[Extent1]', query_sql_text) = 0 THEN 1 ELSE 0 END) AS with_charindex
FROM sys.query_store_query_text
WHERE CHARINDEX(N'query_store', query_sql_text) = 0;
totalno_escapewith_escapewith_charindex
4033

Without escaping, the filter returns zero queries, and you believe there is nothing to see. CHARINDEX searches for a literal string and sidesteps the question.

Then comes the sorting: full reads (SELECT *) of the sensitive tables, then the SSMS “Top Resource Consuming Queries” report over the period, sorted by row count. A data extraction returns many rows. Repeated in every database, this sorting is the work an agent does better than I do.

A login that reads the Query Store but not the tables

The permission that grants access to the Query Store views is VIEW DATABASE STATE, at the database level. Since SQL Server 2022, the narrower VIEW DATABASE PERFORMANCE STATE is enough too. Neither grants SELECT on the tables.

The following script creates the login and, in each online, writable user database, a user that holds only this permission. It runs with sqlcmd and a sysadmin account. The password comes from the AGENT_PWD environment variable, which sqlcmd reads as a scripting variable: it is written neither in the file nor on the command line, where -v AGENT_PWD=... would show it in the process list.

-- Run with a sysadmin account, through sqlcmd
USE master;
IF SUSER_ID(N'agent_qs') IS NULL
    CREATE LOGIN agent_qs WITH PASSWORD = '$(AGENT_PWD)', CHECK_POLICY = ON, DEFAULT_DATABASE = master;
GO

-- One user in each online, writable user database,
-- with VIEW DATABASE STATE as its only permission
DECLARE @db sysname, @proc nvarchar(300);
DECLARE @sql nvarchar(max) = N'
IF DATABASE_PRINCIPAL_ID(N''agent_qs'') IS NULL
    CREATE USER agent_qs FOR LOGIN agent_qs;
GRANT VIEW DATABASE STATE TO agent_qs;';

DECLARE dbs CURSOR LOCAL FAST_FORWARD FOR
    SELECT name
    FROM sys.databases
    WHERE database_id > 4
      AND state_desc = N'ONLINE'
      AND is_read_only = 0;

OPEN dbs;
FETCH NEXT FROM dbs INTO @db;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @proc = QUOTENAME(@db) + N'.sys.sp_executesql';
    EXEC @proc @sql;
    PRINT CONCAT(N'agent_qs: ', @db);
    FETCH NEXT FROM dbs INTO @db;
END;
CLOSE dbs;
DEALLOCATE dbs;

Calling sp_executesql prefixed with the database name ([Db].sys.sp_executesql) runs the batch in that database, without a dynamic USE. On SQL Server 2022 or 2025, replace VIEW DATABASE STATE with VIEW DATABASE PERFORMANCE STATE if you want the narrowest permission. A database created later does not have the user: run the script again, it recreates neither the login nor the existing users. It assumes a new login: an agent_qs that already existed may hold other permissions, which the script does not remove. The database_id > 4 filter skips the system databases, msdb included: if its Query Store is enabled on your server, add it.

Connected as agent_qs, check that it cannot read the data:

USE PachaDataFormation;

SELECT TOP (1) Nom FROM Contact.Contact;
-- Msg 229: The SELECT permission was denied on the object 'Contact'

DBCC SHOW_STATISTICS ('Contact.Contact', nix_contact_nom) WITH HISTOGRAM;
-- Msg 2557: no permission to run DBCC SHOW_STATISTICS

SELECT actual_state_desc, query_capture_mode_desc
FROM sys.database_query_store_options;
-- READ_WRITE, AUTO: the Query Store is readable

The statistics histograms, which contain actual column values, are denied to it, and sys.dm_db_stats_histogram returns zero rows. Lacking VIEW DEFINITION, it does not see the tables in sys.tables either, which returns zero rows. The server-level views (sys.dm_exec_query_stats) are denied to it, as are the Query Store procedures (sp_query_store_remove_query, Msg 12421).

The Microsoft pages for sys.query_store_query_text and sys.query_store_query_hints state that these views require VIEW SERVER STATE, or VIEW SERVER PERFORMANCE STATE since SQL Server 2022, at the server level. I measured that nowhere: a user that holds only VIEW DATABASE STATE reads sys.query_store_query_text on 2019, 2022 and 2025, and sys.query_store_query_hints on 2025. On 2022 and 2025, a user without any permission gets message 262, which names VIEW DATABASE PERFORMANCE STATE. The other Query Store pages state the database-level permission.

Why such a restricted login, when the agent runs on your own machine? An agent reads text it does not control, and the Query Store contains some: a query text can contain instructions written for it (a prompt injection). An agent that is not allowed to read the tables cannot copy them into its answer, even if it is fooled. It can modify neither the data nor the Query Store. Its own queries are recorded there, though, like those of any login.

The Query Store state in each database

Before searching, check what can be searched. This script runs as agent_qs itself: it goes through the databases the login can access (HAS_DBACCESS) and lists those it cannot open.

SET NOCOUNT ON;

CREATE TABLE #state (
    db sysname,
    desired_state_desc nvarchar(60),
    actual_state_desc nvarchar(60),
    readonly_reason int,
    query_capture_mode_desc nvarchar(60),
    stale_query_threshold_days bigint,
    max_storage_size_mb bigint,
    current_storage_size_mb bigint,
    oldest_data datetimeoffset
);

DECLARE @db sysname, @proc nvarchar(300);
DECLARE @sql nvarchar(max) = N'
SELECT DB_NAME(), o.desired_state_desc, o.actual_state_desc, o.readonly_reason,
       o.query_capture_mode_desc, o.stale_query_threshold_days,
       o.max_storage_size_mb, o.current_storage_size_mb,
       (SELECT MIN(start_time) FROM sys.query_store_runtime_stats_interval)
FROM sys.database_query_store_options AS o;';

DECLARE dbs CURSOR LOCAL FAST_FORWARD FOR
    SELECT name
    FROM sys.databases
    WHERE database_id > 4
      AND state_desc = N'ONLINE'
      AND HAS_DBACCESS(name) = 1;

OPEN dbs;
FETCH NEXT FROM dbs INTO @db;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @proc = QUOTENAME(@db) + N'.sys.sp_executesql';
    INSERT INTO #state EXEC @proc @sql;
    FETCH NEXT FROM dbs INTO @db;
END;
CLOSE dbs;
DEALLOCATE dbs;

SELECT * FROM #state ORDER BY db;

-- Databases this login cannot open
SELECT name AS inaccessible_db
FROM sys.databases
WHERE database_id > 4
  AND HAS_DBACCESS(name) = 0;

The oldest_data column gives the start of the oldest interval kept: an earlier period is not covered. An actual_state_desc of READ_ONLY while desired_state_desc is READ_WRITE means a Query Store that captures nothing anymore, and readonly_reason tells why: 65536 when it reached MAX_STORAGE_SIZE_MB.

Connecting the agent

The agent needs a command-line client. Two exist, described on the same page: the original sqlcmd (ODBC, mssql-tools18 package) and go-sqlcmd, a single binary without an ODBC driver. Both read the password from the SQLCMDPASSWORD environment variable when -U is given without -P.

The password must appear neither in the prompt, nor in a versioned file, nor on the command line (where ps shows it), nor in the shell history. I put it in a file only I can read, which I load before starting the agent:

# Once: the password is read without echo and stays out of the history
install -m 600 /dev/null ~/.config/agent-qs.env
read -rs p && printf 'SQLCMDPASSWORD=%q\n' "$p" > ~/.config/agent-qs.env; unset p

# Every session
set -a; . ~/.config/agent-qs.env; set +a
sqlcmd -S myserver,1433 -U agent_qs -d master -C -b -Q "SELECT SUSER_NAME();"
claude    # or codex: the agent inherits the variable

On Windows, with PowerShell 7, $env:SQLCMDPASSWORD = Read-Host -MaskInput in the session does the same (Windows PowerShell 5.1 has no -MaskInput). The -C option trusts the server certificate without validating it, which a self-signed certificate requires: remove it if the server has a valid certificate. The -b option returns a nonzero exit code on a SQL error: without it, go-sqlcmd returns 0 after an error 229. ODBC sqlcmd 18 encrypts the connection by default; go-sqlcmd only encrypts it if you ask with -N.

The agent can read the variable, of course. You are giving it access, which is why the login is only allowed to read the Query Store.

The basic queries

The agent writes its own queries, but showing it the expected shape saves it time and lets you review what it does. They all start from the same joins: the text, the query, the plan, the statistics.

The following search goes through every accessible database, looks for a text and a period, and sorts by total rows returned. It uses CHARINDEX rather than LIKE, and a start date with its time zone offset: the Query Store date columns are datetimeoffset, and the comparison is made on the instant, whatever the offset.

SET NOCOUNT ON;

DECLARE @text nvarchar(400) = N'Contact';                       -- table name, without schema or brackets
DECLARE @since datetimeoffset = '2026-10-21T00:00:00+02:00';   -- start of the period, with its offset

CREATE TABLE #found (
    db sysname,
    query_id bigint,
    last_execution datetimeoffset,
    executions bigint,
    max_rows bigint,
    total_rows bigint,
    query_text nvarchar(max)
);

DECLARE @db sysname, @proc nvarchar(300);
DECLARE @sql nvarchar(max) = N'
SELECT DB_NAME(), q.query_id, MAX(rs.last_execution_time),
       SUM(rs.count_executions), MAX(rs.max_rowcount),
       CAST(SUM(rs.avg_rowcount * rs.count_executions) AS bigint),
       t.query_sql_text
FROM sys.query_store_query AS q
JOIN sys.query_store_query_text AS t ON t.query_text_id = q.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
WHERE CHARINDEX(@text, t.query_sql_text) > 0
  AND CHARINDEX(N''query_store'', t.query_sql_text) = 0
  AND rs.last_execution_time >= @since
GROUP BY q.query_id, t.query_sql_text;';

DECLARE dbs CURSOR LOCAL FAST_FORWARD FOR
    SELECT name
    FROM sys.databases
    WHERE database_id > 4
      AND state_desc = N'ONLINE'
      AND HAS_DBACCESS(name) = 1;

OPEN dbs;
FETCH NEXT FROM dbs INTO @db;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @proc = QUOTENAME(@db) + N'.sys.sp_executesql';
    INSERT INTO #found
    EXEC @proc @sql, N'@text nvarchar(400), @since datetimeoffset', @text, @since;
    FETCH NEXT FROM dbs INTO @db;
END;
CLOSE dbs;
DEALLOCATE dbs;

SELECT db, query_id, last_execution, executions, max_rows, total_rows,
       LEFT(query_text, 200) AS text_start
FROM #found
ORDER BY total_rows DESC;

The CHARINDEX(N'query_store', ...) = 0 filter removes the agent’s own queries on the Query Store views, which the Query Store captures like any others, and any other query whose text contains that word. The totals are those of the intervals kept: an interval that started before @since also counts its earlier executions.

For deletions and updates, the same search with @text = N'DELETE', then N'UPDATE', is enough. It also returns the texts that contain the word elsewhere (an IsDeleted column, an ON DELETE CASCADE): read them. A TRUNCATE TABLE never shows up, even in ALL mode. A query you keep is then read with its plans and its intervals:

USE PachaDataFormation;

DECLARE @query_id bigint = 2;   -- replace with the query_id you found

SELECT i.start_time, i.end_time, rs.count_executions,
       rs.avg_rowcount, rs.max_rowcount,
       rs.avg_duration / 1000. AS avg_duration_ms,
       rs.avg_cpu_time / 1000. AS avg_cpu_ms,
       rs.avg_logical_io_reads
FROM sys.query_store_plan AS p
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
JOIN sys.query_store_runtime_stats_interval AS i
    ON i.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE p.query_id = @query_id
ORDER BY i.start_time;

SELECT plan_id, TRY_CAST(query_plan AS xml) AS plan_xml
FROM sys.query_store_plan
WHERE query_id = @query_id;

The prompt

The prompt describes the situation in two sentences, says what the agent may do and what output is expected. Here is the one I gave Claude Code on the lab, in an empty directory, with one line corrected: the tested version also searched for TRUNCATE statements, which the Query Store does not record. I wrote it in French; this is a translation.

You have access to a SQL Server instance through sqlcmd: server localhost,11980, login
agent_qs, option -C for the certificate. The password is in the SQLCMDPASSWORD environment
variable: do not display it and do not copy it anywhere.

This login only holds the VIEW DATABASE STATE permission in each database: it reads the
Query Store, not the tables.

Explore the Query Store of every database of the instance you can access, for the period
starting on October 21, 2026 at 00:00, Paris time. Look for unusual queries:
- full or large reads of the tables that contain personal data (contacts, addresses,
  invoices);
- ad hoc queries that do not look like the application's code, which goes through
  Entity Framework (@p0 parameters, [Extent1] alias);
- DELETE and UPDATE statements;
- anything else that looks abnormal to you.

For each query you keep, give the database, the query_id, the last execution in Paris
time, the execution count, the number of rows returned and the reason you keep it.

Then say what you could not check: databases without an active Query Store, capture mode,
period covered by the retention.

Constraints: only run SELECT statements on system views, change nothing.
Write your report to rapport.md.

On the lab, Claude Code (Sonnet model) worked for five minutes. It listed the databases, found that SansQS had no Query Store, and found the full reads of Contact.Contact, Contact.Adresse, Inscription.Facture and Inscription.LigneFacture, as well as the read of one address through a three-part name, from Ventes. It set aside the only Entity Framework query as the reference for normal traffic. It wrote on its own that the database was in AUTO mode, that the absence of DELETE therefore proved nothing, and that nothing was captured before 4:41 p.m. on the requested day. It saw neither the 25 slices of Contact.Contact nor the read made from master, which the Query Store had not recorded.

It also flagged my own control queries, which searched the Query Store for the word Adresse, as those of “someone who knows what they are looking for”. Read its report as you would a colleague’s: every query it cites has a query_id you can check.

The same prompt works for a performance analysis: replace the list of things to look for with “the queries whose average duration increased since Monday, with the plan changes”.

Working on a clone

If you do not want the agent to connect to production, even with such a restricted login, DBCC CLONEDATABASE produces a copy of the database that contains the schema, the statistics and the Query Store, without a single row of data. Microsoft intends it for performance troubleshooting. It requires a sysadmin account.

USE master;

-- Flush the runtime statistics still in memory to disk
EXEC PachaDataFormation.sys.sp_query_store_flush_db;

-- Clone without histograms, backed up and verified in the same run
DBCC CLONEDATABASE (PachaDataFormation, PachaDataFormation_Clone)
WITH NO_STATISTICS, BACKUP_CLONEDB;

The options:

OptionEffect
NO_STATISTICSdoes not copy the statistics content (histograms and densities), which holds actual column values. The actual values left are then the literals in the texts, the compiled values in the plans and those in the schema (default values, constraints)
NO_QUERYSTOREdoes not copy the Query Store, not to use here
VERIFY_CLONEDBchecks the clone’s consistency, but implies NO_STATISTICS and NO_QUERYSTORE: not to use here either
BACKUP_CLONEDBbacks up the clone and verifies the backup, in the instance’s default backup directory
SERVICEBROKERcopies the Service Broker catalogs

On the lab, the 2 GB database gave a 16 MB clone and a 6 MB backup, with all the texts, plans and runtime statistics, and zero rows in Contact.Contact. The final message gives the file name: Clone backup succeeded and is stored in 'PachaDataFormation_Clone_1710638105.bak'. Once the backup is verified, remove the clone from the production instance (DROP DATABASE PachaDataFormation_Clone;): Microsoft recommends detaching it once created.

This backup can be restored on another instance of the same or a later version. An Express edition is enough, since the clone is small. I restored the lab’s on a SQL Server 2025 Express. The database users are in the clone, but not the logins: agent_qs is orphaned on the new instance. The simplest fix is to recreate the login with the same SID, read on the source instance with SELECT SUSER_SID('agent_qs');:

-- On the analysis instance, through sqlcmd with the AGENT_PWD environment variable,
-- after copying the file to its backup directory
RESTORE DATABASE PachaDataFormation_Clone
FROM DISK = N'/var/opt/mssql/backup/PachaDataFormation_Clone_1710638105.bak'
WITH MOVE N'PachaDataFormation' TO N'/var/opt/mssql/data/PachaDataFormation_Clone.mdf',
     MOVE N'PachaDataFormation_log' TO N'/var/opt/mssql/data/PachaDataFormation_Clone_log.ldf';

CREATE LOGIN agent_qs WITH PASSWORD = '$(AGENT_PWD)', SID = 0x71EEFB92B941D548AE09FAFA223FC899;  -- SID read on the source instance

ALTER DATABASE PachaDataFormation_Clone SET READ_ONLY WITH ROLLBACK IMMEDIATE;

The restored database was read-write, and so was its Query Store: the agent’s queries would have been captured and mixed with those from production. Read-only, the Query Store captures nothing more. Run this last ALTER DATABASE before handing the database to the agent: it needs exclusive access.

The documentation says a clone is read-only. That holds for the clone of a database without READ_COMMITTED_SNAPSHOT, not for the clone of PachaDataFormation, which has it enabled: on SQL Server 2019, 2022 and 2025, the clone of an RCSI database stays read-write. With BACKUP_CLONEDB, it becomes read-only (measured on 2022 and 2025). Check is_read_only in sys.databases rather than assuming it.

The clone is not supported in production, it only copies the objects Microsoft lists, and it only contains what the Query Store contained at cloning time. The literals and the compiled values of the plans are still there.

A CSV export

The simplest solution gives the agent no access to the server: export the texts and statistics of the period to files, one per database, put them in a directory, open the agent in that directory and ask it the same question. It fits when the server cannot be reached from the machine the agent runs on.

The export query, in an export.sql file, removes line breaks from the texts to keep one line per record. It also converts the text to nvarchar(4000): sqlcmd cuts nvarchar(max) columns at 256 characters, and its -y 0 option, which lifts that limit, cannot be combined with -W.

SET NOCOUNT ON;

SELECT q.query_id, p.plan_id, i.start_time, rs.count_executions,
       rs.avg_rowcount, rs.max_rowcount, rs.avg_duration, rs.avg_cpu_time,
       rs.avg_logical_io_reads,
       CAST(REPLACE(REPLACE(t.query_sql_text, CHAR(13), N' '), CHAR(10), N' ') AS nvarchar(4000)) AS query_sql_text
FROM sys.query_store_query AS q
JOIN sys.query_store_query_text AS t ON t.query_text_id = q.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
JOIN sys.query_store_runtime_stats_interval AS i
    ON i.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE i.end_time >= '2026-10-21T00:00:00+02:00';

It runs database by database. The | separator breaks fewer texts than a comma, but a literal that contains | still shifts the columns, and the file keeps the header and its line of dashes:

sqlcmd -S myserver,1433 -U agent_qs -d PachaDataFormation -C -b -s"|" -W -i export.sql -o PachaDataFormation.csv

The agent can then neither refine its search nor read a plan that was not exported, and the export must be redone for each new question.

In all three cases, restricted login, clone or export, the query texts go to the model provider, and so do the plans except with the export. Decide this with the person responsible for the data before you start.

The limits of the method

  • A trace in the Query Store proves that a query ran. Its absence proves nothing.
  • In AUTO mode, infrequent and cheap ad hoc queries are not captured, and an extraction split into small queries can leave no trace. Queries inside stored procedures are always captured.
  • A query run from master or tempdb is recorded nowhere, and a query run from another database is recorded in that one.
  • A TRUNCATE TABLE is not recorded, even in ALL mode.
  • A query run on a readable secondary replica is not recorded, unless Query Store for secondary replicas, in preview since SQL Server 2025, is enabled.
  • A database without an active Query Store, or whose Query Store switched to READ_ONLY, gives no trace for the period concerned.
  • The history does not go back beyond the retention, 30 days by default, or less if size-based cleanup purged it.
  • The Query Store does not tell who ran the query.
  • Data can leave a database through queries, through a backup or through a copy of the data files. The Query Store only sees the queries.
  • To find out whether rows were deleted, look for DELETE statements in the Query Store, or compare with an earlier backup restored alongside.

If you want a complete trace of the reads of a table, you have to prepare it in advance: SQL Server Audit with a database audit specification on SELECT, limited to the sensitive tables so the disks do not fill up.