Guides Markdown

Analytics with SQL

Every namespace is also a column store. Group, count and aggregate your documents with SQL, combined with exact text and regex matches.

Every namespace is also a column store: the metadata of the documents you search is stored as typed, compressed columns that SQL reads directly. There's no warehouse to load, no pipeline to keep in sync, and nothing running while you don't query. The text index works inside WHERE too: find the log lines with a regex and group them, in one query.

POST /v1/namespaces/{name}/sql with {"sql": "SELECT ..."} (read scope), or the MCP tool sql_query.

How fast

ClickBench's 43 queries over its 10M-row web-analytics table, imported through the API, warm, on one 8-vCPU server:

Hot geometric mean
SkimmerDB, through HTTP (auth, limits, metering and JSON included)9.3 ms
clickhouse-local, same box, official MergeTree schema28.5 ms

SkimmerDB was faster on all 43 queries. Importing those 10M rows as CSV took 23 seconds.

That is one benchmark, warm, with 1.5 GB of RAM cache; with 128 MB it was 14.8 ms and 34 to 37 of 43 faster. The column engine underneath, run on its own at 100M rows on 16 vCPUs, was 2.9-3.2x faster than ClickHouse and 2.1-2.6x faster than DuckDB by geometric mean (hot, 8 and 16 threads), in 25-31% less memory than ClickHouse. Broad SQL is where they're still ahead: see below. More on the benchmarks page.

An example: what your agents did

Write one document per tool call. Give it a unique, meaningful id, the searchable part in text, flat typed metadata, and the time as integer seconds (see Data model):

B=https://api.skimmerdb.com/v1
curl -s $B/namespaces/events/docs -H "Authorization: Bearer $KEY" -d '{"docs": [
  {"id": "agent-1/run-42/step-3", "text": "agent called search: error E504 upstream timeout",
   "meta": {"ts": 1760572937, "agent": "agent-1", "tool": "search", "ok": false, "latency_ms": 912.4, "tokens": 1840}},
  {"id": "agent-1/run-42/step-4", "text": "agent called sql_query",
   "meta": {"ts": 1760572951, "agent": "agent-1", "tool": "sql_query", "ok": true, "latency_ms": 38.0, "tokens": 220}}
]}'
events = db.namespace("events")
events.write([
    {"id": "agent-1/run-42/step-3", "text": "agent called search: error E504 upstream timeout",
     "meta": {"ts": 1760572937, "agent": "agent-1", "tool": "search", "ok": False, "latency_ms": 912.4, "tokens": 1840}},
    {"id": "agent-1/run-42/step-4", "text": "agent called sql_query",
     "meta": {"ts": 1760572951, "agent": "agent-1", "tool": "sql_query", "ok": True, "latency_ms": 38.0, "tokens": 220}},
])
const events = db.namespace("events");
await events.write([
  { id: "agent-1/run-42/step-3", text: "agent called search: error E504 upstream timeout",
    meta: { ts: 1760572937, agent: "agent-1", tool: "search", ok: false, latency_ms: 912.4, tokens: 1840 } },
  { id: "agent-1/run-42/step-4", text: "agent called sql_query",
    meta: { ts: 1760572951, agent: "agent-1", tool: "sql_query", ok: true, latency_ms: 38.0, tokens: 220 } },
]);

Then ask:

sql
-- calls, failures and latency per tool
SELECT tool, COUNT(*) AS calls, SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END) AS errors,
       AVG(latency_ms) AS avg_ms, MAX(latency_ms) AS max_ms
FROM docs GROUP BY tool ORDER BY calls DESC

-- an hourly series for a dashboard
SELECT DATE_TRUNC('hour', ts) AS hour, COUNT(*) AS calls, SUM(tokens) AS tokens
FROM docs WHERE ts >= 1760572800 GROUP BY hour ORDER BY hour

-- the text index and SQL together: who hits 5xx errors
SELECT agent, COUNT(*) AS n FROM docs WHERE grep(text, 'E5[0-9]{2}') GROUP BY agent ORDER BY n DESC

-- one run, by its id
SELECT id, tool, ok, latency_ms FROM docs WHERE id LIKE 'agent-1/run-42/%' ORDER BY id

-- distinct agents active in a window
SELECT COUNT(DISTINCT agent) FROM docs WHERE ts >= 1760572800 AND ts < 1760659200

Sending one:

curl -s $B/namespaces/events/sql -H "Authorization: Bearer $KEY" -d @- <<'EOF'
{"sql": "SELECT tool, COUNT(*) AS calls FROM docs GROUP BY tool ORDER BY calls DESC"}
EOF
res = events.sql("SELECT tool, COUNT(*) AS calls FROM docs GROUP BY tool ORDER BY calls DESC")
print(res.columns, res.rows)
const res = await events.sql("SELECT tool, COUNT(*) AS calls FROM docs GROUP BY tool ORDER BY calls DESC");
console.log(res.columns, res.rows);

The answer:

Answer
{"namespace": "events", "columns": ["tool", "calls"], "types": ["text", "int"],
 "rows": [["sql_query", 136], ["search", 134]], "chunks": 1, "scanned": 1,
 "partial": false, "truncated": false, "took_ms": 0.9, "temperature": "warm", "store_gets": 0, "store_bytes": 0}

DATE_TRUNC gives a time value ("2025-10-16 01:00:00"); booleans like ok are 1 / 0.

Columns and types

The columns are id (the document id) and every top-level meta key. Types come from the values: integers and booleans are int, other numbers float, anything else text (objects and arrays as their JSON). A key whose values change type is read at the widest type seen (int < float < text). Missing and JSON null values are SQL NULL. Run SHOW COLUMNS to see every column, its type and how many segments hold it.

text itself isn't a column: reach it with contains(text, '...') and grep(text, '...').

Patterns

  • Time series. Times as integer seconds (or an imported time column), DATE_TRUNC('minute' | 'hour' | 'day', ts), and a WHERE ts >= ... window. Blocks outside the window are skipped by their min/max without being read.
  • Rates. Count the parts in one query (COUNT(*), SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END)) and divide in your code: SQL arithmetic is + and - for now.
  • Top-N. GROUP BY ... ORDER BY n DESC LIMIT 10.
  • Needle in the logs. contains(text, 'payment failed') (literal, ignoring case) and grep(text, 'ERR-[0-9]+') (RE2) anywhere in WHERE, up to 8 a query, combined with any other condition. Blocks with no match are skipped before a column is read.
  • The current state, not the history. A replaced or deleted document never counts, so a namespace of ticket-<n> documents answers "how many are open now". For the history, write each change as its own event (ticket-<n>/<version>).

From files

Import CSV, NDJSON or Parquet straight into columns. A CSV with a header row gets typed columns, and the columns you name in text= stay searchable:

orders.csv
order_id,placed_at,country,amount,note
A-1,2026-10-01 09:15:00,US,120.5,gift wrap please
A-2,2026-10-01 10:40:00,DE,80,
A-3,2026-10-02 08:05:00,US,42.25,refund requested

Import it:

curl
curl -s "$B/namespaces/orders/import?format=csv&id=order_id&text=note" \
  -H "Authorization: Bearer $KEY" --data-binary @orders.csv

placed_at becomes a time column you can compare with '2026-10-01' and bucket by day:

sql
SELECT DATE_TRUNC('day', placed_at) AS d, country, SUM(amount) AS revenue, COUNT(*) AS orders
FROM docs WHERE placed_at >= '2026-10-01' GROUP BY d, country ORDER BY d, revenue DESC

SELECT COUNT(*) FROM docs WHERE contains(text, 'refund')

Where it stands against ClickHouse and DuckDB

The engine is fast on what it runs; the SQL is a subset, and growing.

Today
SELECT ... FROM docs with WHERE, GROUP BY, HAVING, ORDER BY, LIMIT / OFFSETYes
COUNT, COUNT(DISTINCT), SUM, AVG, MIN, MAX, CASE WHEN, COALESCE, LIKE, IN, IS NULL, DATE_TRUNC, EXTRACT(MINUTE ...), REGEXP_REPLACE, LENGTHYes, with SQL NULL semantics
Full-text and regex predicates in WHERE (contains, grep)Yes, through the text index
Search, vectors and SQL on one copy, on S3, nothing running while idleYes
Joins, subqueries, UNION, CTEs, window functionsNot yet: one namespace, one table a query
*, /, %, BETWEEN, NULLIF, STDDEV, MEDIAN, SELECT DISTINCTNot yet (use GROUP BY for distinct values)
Answer sizeUp to 10,000 rows (truncated: true past it); LENGTH counts bytes

Dashboards, product and agent analytics, log and trace exploration, and "count and group what my search found" fit today, on data already in SkimmerDB. A star schema with joins, or a BI tool generating arbitrary SQL, is still a warehouse's job.

Limits and cost

A SQL query is metered and limited as one query. Each plan sets a time limit (10 seconds on free, 30 on pro) and how many SQL queries a tenant runs at once (2 and 8; 429 past it). A query's groups, distinct values and rows in flight, and what it reads from object storage, are capped by a share of the server's memory: past a cap it stops with a 422 (or 413 for reads, refused before reading anything), never taking the server down.

The full dialect, operators, NULL rules and errors: SQL reference.