← Files FactIQARCHIVED FILE
references/data/sql-guide.md
20.8 KB · Nov 2, 2026 · 12:30 UTC
# FactIQ SQL Guide
SQL runs read-only with one default schema per call — the `run_sql` MCP tool,
with `schema="bls"` and `sql="..."`. The server sets `search_path` to that
schema, so reference its tables bare (`series`, not `bls.series`); other
schemas stay reachable by qualified name (`korea_trade.series`,
`eu_comext_lookup.product_codes`). Statements are capped at 30 seconds, and
**every result is capped at 50 rows** — aggregate in SQL to the granularity
you actually need rather than pulling raw rows (see "Pivoting" and
"Efficiency" below).
## Table structure (identical in every schema)
- **`series`** — the catalog. Key columns: `series_id`, `series_title`,
`human_friendly_title`, `human_friendly_description`, `dataset_code`,
`frequency`, `measurement_units`, `adjusted_for_seasonality`, `state`,
`county_or_metro_area`, `begin_time`, `end_time`, `data_type`
(`timeseries` or `tabular`), `tabular_columns`.
- **`data_points`** — `series_id`, `time` (date), `value` (numeric).
- **`dimensions`** — `series_id`, `dimension_type`, `dimension_code`,
`dimension_name`. Useful for discovery when titles are uninformative.
- **`tabular_data`** — JSONB `row_data` per row, for `data_type = 'tabular'`
series only.
- **`compound_series`** — curated derived series with ids like
`COMPOUND::...`. Fetch them with the `get_series` tool, not raw SQL.
The `get_data_catalog` tool returns the full DDL.
## Always-true conventions
- **`frequency` values are lowercase**: `'monthly'`, `'quarterly'`,
`'annual'`, `'weekly'`, `'semiannual'`. `frequency = 'Monthly'` matches
nothing.
- **`dataset_code` is lowercase** — use ILIKE or lowercase literals.
- **Match periods on the calendar month, never on the exact date.** The day
stored inside `data_points.time` varies by source: most store the first day
of the period, but `frb`, `rbi`, `fhfa` and `treasury` store monthly values
on the last day of the month (`2025-02-28`, `2025-03-31`), and `frb`, `rbi`,
`fhfa` and `cbo` store quarters on quarter-end dates. An exact-date join
such as `prior.time = cur.time - interval '1 year'` finds nothing for those
(February 29 minus one year is February 28). Join and compare on
`date_trunc('month', time)` (or `date_trunc('quarter', time)` for quarterly
series):
`date_trunc('month', prior.time) = date_trunc('month', cur.time) - interval '1 year'`.
- The server rewrites `series_title` to
`COALESCE(human_friendly_title, series_title)` automatically, normalizes
frequency filters, and turns `data_points.series_id` pattern filters into
semi-joins on `series`; the response's `transformed_query` shows what
actually ran.
- Results come back enriched with `constituent_series` metadata
(titles, units, source) for every series_id touched — use it for chart
source attribution.
## Exploratory queries first
Before fetching from a dataset you haven't touched, look at 5 rows of its
catalog to learn naming conventions and units:
```sql
SELECT series_id, series_title, dataset_code, frequency,
adjusted_for_seasonality, measurement_units
FROM series WHERE dataset_code ILIKE '<dataset>' LIMIT 5
```
Run these with `explore=true` so the server treats them as exploration.
## Keep SQL simple and broad
Don't stack ILIKE conditions — you'll miss valid rows and get zero results.
A broad query returning 50 rows beats a narrow one returning 0 (results are
capped at 50 rows either way).
```sql
-- Bad: over-filtered, likely 0 rows
SELECT series_id, series_title FROM series
WHERE dataset_code ILIKE 'jt' AND series_title ILIKE '%job openings%total nonfarm%'
AND series_title ILIKE '%level%' AND measurement_units ILIKE '%level%'
-- Good: broad, let the results guide you
SELECT series_id, series_title, measurement_units FROM series
WHERE dataset_code ILIKE 'jt' AND series_title ILIKE '%job openings%'
AND adjusted_for_seasonality = true
LIMIT 20
```
Prefer filtering on indexed columns (`series_id`, `dataset_code`) over text
scans. For text searches use two steps: find series_ids first, then fetch
their data.
## Pattern-matching `series_id` on `data_points`
`data_points` is enormous and its index does not serve LIKE/ILIKE; an
unindexed pattern scan of it dies at the 30s timeout. The server covers one
case for you: a LIKE/ILIKE on `data_points.series_id` in a WHERE clause is
rewritten into `series_id IN (SELECT series_id FROM series WHERE ...)` (the
response's `transformed_query` shows it), so it runs through the index. The
rewrite covers WHERE clauses only — a pattern inside a projection
(`SUM(CASE WHEN series_id LIKE '%\_5700' THEN value END)`) is untouched, and
is fine *only if* the WHERE clause already narrows the rows. The same pattern
against the small `series` catalog is always fast. (Exception: the Eurostat
Comext country catalogs are not small — millions of series each — and their
text collation defeats prefix-index use entirely; never pattern-scan
`series_id` there. See the Comext section below.) When in doubt, resolve ids
explicitly first:
```sql
-- Step 1 (fast): resolve the id list on the catalog
SELECT series_id FROM series WHERE series_id LIKE 'us\_census\_hs\_M\_10d\_280530%'
-- Step 2 (fast): fetch with an explicit IN list — uses the index
SELECT series_id, time, value FROM data_points
WHERE series_id IN ('...', '...')
```
## National vs. sub-national series — the classic trap
Geographically decomposed datasets are dominated by state/region rows; the
single national aggregate is a needle. For a national question you must
filter for the national key, not just the metric.
Caveat: server-side dataset descriptions sometimes quote *raw* title
conventions (pipe-delimited keys like `State: All India` or prefixes like
`CPI 2024`). Because your filters run against the COALESCEd friendly titles,
those exact patterns often match nothing. Filter on the natural-language
fragment instead:
- MOSPI CPI national aggregate → `series_title ILIKE '%All India%'` (add
`'%Combined%'` for the combined rural+urban sector), NOT
`'%State: All India%'`.
- MOSPI WPI is national-only — no state key at all. Confirm a dataset is
geographically split before forcing a geography filter.
- To identify a CPI base-year family when titles don't say: read the
series description from a `series` fetch, or check the base-year
calendar average ≈ 100 in the data itself.
Never chart a single state/region as if it were the national figure. If you
can't find the national aggregate, say so rather than substituting.
## Year-over-year on monthly data — never use LAG(value, 12)
A row offset equals a period offset only when every period is present. Series
have holes: a month the source never published, a quarter outside the
requested window, a period a WHERE clause removed. For the twelve rows after
a hole, `LAG(value, 12)` compares each month with the month **thirteen**
periods back (`LEAD(value, 12)` does the same for the twelve rows before it),
the numbers stay plausible, and nothing errors.
Bad — silently wrong after any gap:
```sql
SELECT time, 100 * (value / LAG(value, 12) OVER (ORDER BY time) - 1) AS yoy_pct
FROM data_points
WHERE series_id = 'CUUR0000SA0' AND time >= '2019-01-01'
ORDER BY time
```
Good — join on the calendar month, so a missing prior period gives no row (or
a null with a LEFT JOIN) instead of a wrong number. Compare
`date_trunc('month', time)`, not `time` itself: the stored day of the month
differs between sources (first of the month for most, last day of the period
for some), and an exact-date join silently finds nothing for those.
```sql
SELECT cur.time,
100 * (cur.value / prior.value - 1) AS yoy_pct
FROM data_points cur
JOIN data_points prior
ON prior.series_id = cur.series_id
AND date_trunc('month', prior.time) = date_trunc('month', cur.time) - interval '1 year'
WHERE cur.series_id = 'CUUR0000SA0' AND cur.time >= '2020-01-01'
ORDER BY cur.time
```
For quarterly series use `date_trunc('quarter', ...)` on both sides; for
month-over-month subtract `interval '1 month'`.
Also good — build a complete date spine first; on a spine every period has a
row, so a row offset is a period offset and LAG is correct:
```sql
WITH spine AS (
SELECT generate_series('2019-01-01'::timestamp, '2026-06-01', interval '1 month') AS time
),
cpi AS (
SELECT s.time, d.value
FROM spine s
LEFT JOIN data_points d
ON date_trunc('month', d.time) = s.time AND d.series_id = 'CUUR0000SA0'
)
SELECT time, value, 100 * (value / LAG(value, 12) OVER (ORDER BY time) - 1) AS yoy_pct
FROM cpi WHERE time >= '2020-01-01' ORDER BY time
```
For one series, `get_series` with `transform="yoy_pct"` (or `"yoy_diff"` for a
rate) does the date-matched calculation on the server. For several series or
a merged table, `scripts/series_math.py yoy` matches on the calendar period
too.
The result's `coverage_note` and `missing_periods` name the holes in the rows
you received, and `run_sql` says so when the statement itself uses a fixed
LAG/LEAD offset over time. Act on them: state the missing period(s) in the
answer, leave the month blank in a chart rather than interpolating, and label
an aggregate that spans a hole as partial ("Q4 2025 average of two months").
## HS trade datasets
**Don't hand-write bilateral-trade SQL — generate it.** The plugin bundles
stdlib-only generators that encode every schema's ID grammar, partner codes,
units, and HS-level rules (run `--help` for subcommands):
`{plugin_root}/scripts/trade_sql.py` covers the six national customs schemas (census
`us_census_hs`, `china_customs`, `india_trade`, `korea_trade`, `japan_trade`,
`taiwan_trade`), `{plugin_root}/scripts/comext_sql.py` covers the 27 Eurostat
Comext schemas, and `{plugin_root}/scripts/hs_codes.py` resolves HS codes <->
names offline. Resolve `{plugin_root}` as described in `SKILL.md` before
invoking them; never assume the shell is at the plugin root.
For broad bilateral merchandise-trade questions, use
`references/report-patterns/bilateral-trade.md`; it has the report trigger and ready SQL
templates for monthly totals, latest-month YoY, YTD YoY, annual totals, and top
HS product drivers.
The core guardrails:
- For trade schemas other than Comext, filter with `dimensions` (`partner`,
`flow`, `commodity`, `hs_level`) instead of parsing series IDs. Comext uses
the indexed workflow below.
- Use exactly one HS level in an aggregation. HS-6 is the default for
cross-country comparison; national 8/10-digit lines are finer detail and must
not be summed with HS-6 rows.
- Keep value and quantity/weight series separate. For example, Korea KCS has
value in `US$ Thousand` and weight in `kg`; a value report should filter
`measurement_units = 'US$ Thousand'`.
- Normalize units before comparing reporters. For example, India DGCI&S values
are `US$ Million`, while Korea KCS values are `US$ Thousand`.
- Query each reporter schema separately by default and compare in local
computation — mirror statistics differ on units, timing, valuation,
re-exports, and revisions, so state the reporter view explicitly. The
one-schema scope is only a default search path, not a wall: qualified
cross-schema references do execute, so a `UNION ALL` of per-reporter
aggregations in one call is fine when every branch converts to a common
unit.
### Eurostat Comext country schemas
Comext uses one physical schema per EU reporter, named
`eu_comext_<lowercase ISO2>`: for example, `eu_comext_de`, `eu_comext_fr`,
and `eu_comext_nl`. There is no `eu_comext` data schema. Each country catalog
is large enough that title searches and dimension-value scans can reach the
30-second SQL limit.
Exact series IDs are mandatory here, not just faster. Each country catalog
holds millions of series, and the text collation is not byte-ordered, so
`series_id LIKE 'prefix%'` cannot run on the index (it scans the table and
hits the 30-second timeout) and `>=`/`<` string ranges over `series_id`
return wrong or empty results. Equality joins on constructed IDs are index
probes and stay fast at any scale.
Use these rules:
- Never search or filter Comext's `dimensions` table by dimension values.
- Search the shared `eu_comext_lookup.product_codes` table by `product_code`,
`product_name`, `hs2_code`, `hs4_code`, or `hs6_code`.
It is a complete product-code lookup, including historical codes and their
first and last observed dates.
- Build exact series IDs and join them to `data_points`, whose `series_id`
index makes the fetch fast. The detailed ID shape is
`eu_comext_{M|X}_{reporter}_{partner}_{te|ti|tl}_p1_cn8_{product_code}_{eur|kg|su}`.
`M` means imports and `X` exports. Reporter and partner are lowercase ISO2
codes, except that `xi` is Eurostat's code for Northern Ireland. The stored
trade token follows the partner's status in each month: `te` outside the EU,
`ti` inside the EU, and `tl` for Northern Ireland from 2021. A range that
crosses an EU accession or Great Britain's February 2020 exit must query both
exact series with non-overlapping date bounds. Use `comext_sql.py` so these
changes are handled automatically. Its `uk` input combines `gb` with the
separately stored `xi` series from 2021; `gb` alone excludes Northern Ireland
from that point.
- Use `_eur` for trade value, `_kg` for weight, and `_su` for supplementary
quantity. Never sum these metrics together. Supplementary units are
product-specific, so return `series.measurement_units` with an exact CN8
supplementary trend and group by that unit before summing reporters.
- For all-goods totals, use the exact `cn6_total` series instead of summing
every CN8 product.
- For several reporters, either run one already-aggregated query per reporter
schema and combine the small results locally, or `UNION ALL` the
per-reporter aggregations in a single call — a union of exact-ID joins
across all 27 member schemas completes well within the 30-second limit.
What times out is pattern scanning, not combining: every branch must use
the exact-ID join, never a `series_id` pattern.
To inspect product mappings, qualify the shared table from any country query:
```sql
SELECT product_code, product_name, hs2_code, hs4_code, hs6_code,
first_observed, last_observed
FROM eu_comext_lookup.product_codes
WHERE product_name ILIKE '%textile%'
ORDER BY product_code
LIMIT 50;
```
For a broad category, keep the lookup and aggregation in the same SQL call so
the 50-row tool limit does not truncate the CN8 code list. This tested example
returns monthly German-reported textile imports from China in 2025. Run it
with `schema="eu_comext_de"`:
```sql
SELECT dp.time::date AS month,
SUM(dp.value) AS value_eur
FROM eu_comext_lookup.product_codes p
JOIN data_points dp
ON dp.series_id =
'eu_comext_M_de_cn_te_p1_cn8_' || lower(p.product_code) || '_eur'
WHERE p.hs2_code BETWEEN '50' AND '63'
AND p.is_numeric_cn8
AND p.first_observed <= DATE '2025-12-01'
AND p.last_observed >= DATE '2025-01-01'
AND dp.time >= DATE '2025-01-01'
AND dp.time < DATE '2026-01-01'
GROUP BY dp.time
ORDER BY dp.time;
```
Replace the reporter, partner, flow, trade token, dates, and HS grouping for
the question. For one named CN8 product, construct its exact ID directly. For
all goods, query the total series:
```sql
SELECT time::date AS month, value AS value_eur
FROM data_points
WHERE series_id = 'eu_comext_M_de_cn_te_p1_cn6_total_eur'
AND time >= DATE '2025-01-01'
AND time < DATE '2026-01-01'
ORDER BY time;
```
For several reporters in one call, `UNION ALL` the same exact-ID aggregation
per country schema. This tested example returns 2025 German- and
French-reported electronics (HS chapter 85) imports from China; run it with
`schema="eu_comext_de"` (any Comext schema works as the default):
```sql
SELECT cc, SUM(v) AS value_eur
FROM (
SELECT 'de' AS cc, dp.value AS v
FROM eu_comext_lookup.product_codes p
JOIN eu_comext_de.data_points dp
ON dp.series_id =
'eu_comext_M_de_cn_te_p1_cn8_' || lower(p.product_code) || '_eur'
WHERE p.hs2_code = '85'
AND dp.time >= DATE '2025-01-01' AND dp.time < DATE '2026-01-01'
UNION ALL
SELECT 'fr', dp.value
FROM eu_comext_lookup.product_codes p
JOIN eu_comext_fr.data_points dp
ON dp.series_id =
'eu_comext_M_fr_cn_te_p1_cn8_' || lower(p.product_code) || '_eur'
WHERE p.hs2_code = '85'
AND dp.time >= DATE '2025-01-01' AND dp.time < DATE '2026-01-01'
) t
GROUP BY cc
ORDER BY cc;
```
## Company screener
`screener.companies` is a view with one row per US-listed company that files
financial statements with the SEC, about 2,400 rows. The `screener` schema has
no `series` or `data_points` tables; filter the view on its columns with
`run_sql(schema="screener", ...)`.
- **Identity and labels**: `ticker`, `company_name`, `sector`, `industry`
(Nasdaq's labels, not GICS), `sic_code`, `sic_description`,
`naics_sector_code`, `country`, `issuer_id`, `cik`.
- **Price** (US dollars, from Nasdaq's public stock list): `price`,
`price_change_pct`, `volume`, `market_cap` (the whole company, all share
classes), `enterprise_value` (`market_cap + total_debt - cash`).
- **Ratios, computed when you query**: `price_to_sales`, `price_to_earnings`,
`price_to_book`, `ev_to_sales`, `ev_to_operating_income`.
- **Latest twelve months, from the company's filings** (in
`reporting_currency`): `revenue_ttm`, `gross_profit_ttm`,
`operating_income_ttm`, `net_income_ttm`, `operating_cash_flow_ttm`,
`capex_ttm`, `free_cash_flow_ttm`, and the fractions `gross_margin`,
`operating_margin`, `net_margin`, `revenue_growth_yoy` (0.25 = 25%).
- **Latest balance sheet**: `cash`, `total_debt`, `stockholders_equity`,
`shares_outstanding`.
- **How fresh the row is**: `price_as_of` (time of the last good price
refresh), `price_changed_at`, `fundamentals_period_end` (the date the twelve
months end), `ttm_basis` (`four_quarters`, or `latest_annual` when four
back-to-back quarters were not filed), `fundamentals_updated_at`.
```sql
-- Technology companies above $50B with price-to-sales above 10
SELECT ticker, company_name, market_cap, price_to_sales, revenue_growth_yoy,
price_as_of
FROM screener.companies
WHERE sector = 'Technology' AND price_to_sales > 10 AND market_cap > 5e10
ORDER BY market_cap DESC
LIMIT 25;
```
Rules that change the answer:
- Look at the labels before you filter on one:
`SELECT sector, count(*) FROM screener.companies GROUP BY 1`. A few large
companies have no Nasdaq sector (Berkshire Hathaway); `sic_description` is
filled for nearly every row.
- A ratio is NULL when its bottom number is zero or negative, so a loss-making
company has no `price_to_earnings`. It is also NULL when
`reporting_currency` is not USD (about 180 filers report in EUR, CAD, CNY and
others; no currency is converted). `price_to_sales < 2` therefore leaves
those companies out. Say so when it matters.
- Prices are delayed about fifteen minutes and refresh only while the US
market is open. State `price_as_of` in the answer. Call `get_market_data`
when the user needs the current price of one company.
- Always `ORDER BY` and `LIMIT`; count the matches first when the list may be
long.
- `screener.daily_history` (`ticker`, `trade_date`, `price`, `market_cap`,
`enterprise_value`, `price_to_sales`, `price_to_earnings`, `price_to_book`,
`ev_to_sales`) has one row per company per trading day and starts in
September 2026.
- For the filed values behind a row, call `search_company_filings` with the
ticker, or join `filings` tables on `issuer_id`.
## Pivoting to wide format
Chart data wants one row per time period with one column per series. Use
CASE WHEN with **GROUP BY on time** (omitting GROUP BY fails or duplicates
rows):
```sql
SELECT time,
MAX(CASE WHEN series_id = 'LNS14000000' THEN value END) AS unemployment_rate,
MAX(CASE WHEN series_id = 'LNS11300000' THEN value END) AS participation_rate
FROM data_points
WHERE series_id IN ('LNS14000000', 'LNS11300000')
AND time >= '2019-01-01'
GROUP BY time
ORDER BY time
```
A period with no row in `data_points` is absent from the pivot, not a null
cell: the time axis skips it. Read the result's `coverage_note` before
computing changes across rows.
## Tabular data
Series with `data_type = 'tabular'` store rows in `tabular_data.row_data`
(JSONB), described by `series.tabular_columns`. The `get_series` tool handles
them transparently (returns columns + results like a timeseries) — prefer it.
For direct SQL: `row_data->>'column_name'` extracts text; cast with `::numeric`
for math.
## Efficiency
- Most questions need 2–4 data calls. Batch independent fetches in one turn.
- Don't re-fetch via `get_series` what a `run_sql` call already returned — both
are equally chartable.
- Use `get_series` for 1–2 known ids; `run_sql` for 3+ series, joins,
aggregations.
- Results cap at 50 rows. If a fetch comes back `"truncated": true`, aggregate
in SQL (`GROUP BY date_trunc(...)`, a SUM/AVG/rank) rather than trying to
pull the rest — a chart needs the aggregated result anyway.
- Zero rows means your filter missed — broaden the term or drop a condition
and rerun. You revise faster and cheaper than the server's `auto_retry`
LLM reviser.
SHA-256: 4924bd8ae696ef3ee8aaa5c5dbb4ba53356cefa82653d65b3040df407e800027