CLI ยท commit d1f025f

querylens

SQL workload analyzer: fingerprints, N+1 detection, index advice

Download querylens.zip
Quickstart
unzip querylens.zip && cd querylens
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

querylens

querylens turns raw database query logs into actionable performance findings: which query shapes cost the most, where N+1 patterns hide, and which indexes would help. It reads logs you already have (PostgreSQL, MySQL, Django, pg_stat_statements), needs nothing installed on the database server, and has a single dependency (click).

Features

Install

cd querylens
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
source .venv/bin/activate

Quickstart

python examples/generate_logs.py            # optional: regenerate the sample logs
querylens top examples/postgres.log
querylens nplus1 examples/django.log
querylens indexes examples/postgres.log --schema examples/schema.sql
querylens lint examples/postgres.log --schema examples/schema.sql
querylens diff examples/postgres.log examples/after/postgres.log
querylens report examples/django.log --schema examples/schema.sql -o report.html

The examples simulate an e-commerce app with an N+1 on order items, a missing shop_order(customer_id, created_at) index, OFFSET pagination, and a few more anti-patterns. examples/ holds the same workload as a PostgreSQL log, Django log and csvlog, plus a MySQL slow log, a pg_stat_statements export, an ad-hoc SQL file, schema.sql, and an after/ log (a later deploy) for diff.

Usage

All commands take one or more log files (- for stdin, which needs --format). Common options: --format [postgres|postgres-csv|mysql|django|pg_stat|sql], --request-id-regex REGEX (first group is the id; default (?:req|request_id|trace_id|rid)[=:]\s*([\w-]+)), --json.

top

querylens top LOGS [--by total|p95|calls] [--limit N] [--width W]

$ querylens top examples/postgres.log --limit 5 --width 130
2112 queries, 18 distinct shapes, 10,648 ms total

ID        CALLS  TOTAL ms   MEAN    P50    P95    P99    MAX  SHARE  QUERY
--------  -----  --------  -----  -----  -----  -----  -----  -----  -------------------------------------------------------------
8a0db2d9      3     2,742    914    907    907    907    977  25.7%  SELECT id FROM shop_customer WHERE id NOT IN (SELECT custo...
e9442640     61     2,514  41.21  37.72  80.65  90.74  90.74  23.6%  SELECT shop_order.id, shop_order.customer_id, shop_order.s...
3ffb958a     61     1,557  25.53  24.29  41.68  52.99  66.32  14.6%  SELECT count(*) AS __count FROM shop_order WHERE shop_orde...
4fee8fd8      3       833    278    290    290    290    293   7.8%  SELECT * FROM shop_order WHERE status = ?
ddb33523     42       719  17.13  15.64  28.50  30.88  34.51   6.8%  SELECT shop_product.id, shop_product.category_id, shop_pro...

nplus1

querylens nplus1 LOGS [--window-ms 500] [--min-repeats 5] [--limit N]

$ querylens nplus1 examples/django.log
#1 5ba07463  926 queries in 61 bursts (largest burst 24), est. savings 34.65 ms
   repeated: SELECT shop_orderitem.id, shop_orderitem.order_id, shop_orderitem.product_id, shop_orderitem.quantity, shop_orderitem.unit_price FROM shop_orderitem W
   after:    SELECT shop_order.id, shop_order.customer_id, shop_order.status, shop_order.total, shop_order.created_at FROM shop_order WHERE shop_order.customer_id 
   fix:      Django: prefetch_related('orderitem_set') on the parent queryset (or select_related if order_id is a forward FK); this collapses N queries into 1 IN-query.

indexes

querylens indexes LOGS --schema schema.sql [--max-columns 4] [--limit N]

$ querylens indexes examples/postgres.log --schema examples/schema.sql
#1 shop_order(customer_id, created_at)  benefit 4,567 ms (42.9% of workload), 140 calls, 3 query shapes
   CREATE INDEX idx_shop_order_customer_id_created_at ON shop_order (customer_id, created_at);

#2 shop_order(created_at)  benefit 2,742 ms (25.7% of workload), 3 calls, 1 query shapes
   CREATE INDEX idx_shop_order_created_at ON shop_order (created_at);

Redundant existing indexes:
   idx_customer_email (email) on shop_customer is covered by shop_customer_email_key  ->  DROP INDEX idx_customer_email;
   idx_product_category (category_id) on shop_product is covered by idx_product_category_created  ->  DROP INDEX idx_product_category;

lint

querylens lint LOGS [--schema schema.sql] [--offset-threshold 1000] [--scan-rows 1000]. MISSING_LIMIT needs row counts (MySQL slow log, pg_stat_statements); IMPLICIT_CAST and the indexed-column check for FUNC_ON_COLUMN need --schema.

$ querylens lint examples/postgres.log --schema examples/schema.sql
SEVERITY  CODE                  ID        TOTAL  CALLS  FINDING
--------  --------------------  --------  -----  -----  ------------------------------------------------------------------------------
warning   NOT_IN_SUBQUERY       8a0db2d9  2,742      3  id NOT IN (subquery) is slow and returns nothing if the subquery yields a NULL
warning   LEADING_WILDCARD      ddb33523  719       42  LIKE '%...' on name cannot use a btree index
warning   OFFSET_PAGINATION     c3b0aa1c  249       97  OFFSET up to 2232: the database reads and discards every skipped row
warning   FUNC_ON_COLUMN        7e9bdfda  198       40  lower(email) in WHERE prevents use of a plain index on email
info      SELECT_STAR           4fee8fd8  833        3  SELECT * fetches every column
info      OR_DIFFERENT_COLUMNS  375363a4  312       40  OR across different columns (email, phone)

NOT_IN_SUBQUERY: Use NOT EXISTS (SELECT 1 ... WHERE ...) or an anti-join
LEADING_WILDCARD: Use a trigram (pg_trgm GIN/GiST) index or full-text search, or anchor the pattern
OFFSET_PAGINATION: Use keyset pagination: WHERE (sort_col, id) < (last_seen_value, last_seen_id) ORDER BY sort_col DESC, id DESC LIMIT n
FUNC_ON_COLUMN: Compare the bare column, or create an expression index on lower(email)
SELECT_STAR: List only the columns the caller uses; it enables index-only scans and cuts transfer size
OR_DIFFERENT_COLUMNS: Rewrite as UNION ALL of single-column queries so each branch can use its own index

diff

querylens diff BEFORE AFTER [--p95-threshold 1.25] [--min-delta-ms 1] [--call-threshold 1.5]

$ querylens diff examples/postgres.log examples/after/postgres.log --limit 4
before: 2112 queries, 10,648 ms   after: 4154 queries, 7,621 ms

New query shapes
ID        CALLS  TOTAL   P95  QUERY
--------  -----  -----  ----  ----------------------------------------------------------------------------
063edd91   2088    680  0.61  SELECT count(*) AS __count FROM shop_review WHERE shop_review.product_id = ?

Gone query shapes
  none

p95 regressions
ID        P95 BEFORE  P95 AFTER  RATIO  QUERY
--------  ----------  ---------  -----  --------------------------------------------------------------------------------------------------------------
c3b0aa1c        5.00      19.11   3.8x  SELECT shop_product.id, shop_product.category_id, shop_product.sku, shop_product.name, shop_product.price, ...
78ba22f9        1.42       2.44   1.7x  INSERT INTO shop_orderitem(product_id, quantity, unit_price) VALUES (...)

p95 improvements
ID        P95 BEFORE  P95 AFTER  RATIO  QUERY
--------  ----------  ---------  -----  --------------------------------------------------------------------------------------------------------------
e9442640       80.65       1.34  0.02x  SELECT shop_order.id, shop_order.customer_id, shop_order.status, shop_order.total, shop_order.created_at FR...
c5a8bf3b       50.91       1.34  0.03x  SELECT shop_order.id, shop_order.customer_id, shop_order.status, shop_order.total, shop_order.created_at FR...
3ffb958a       41.68       1.03  0.02x  SELECT count(*) AS __count FROM shop_order WHERE shop_order.customer_id = ?
375363a4       15.64       9.12  0.58x  SELECT shop_customer.id, shop_customer.email, shop_customer.name, shop_customer.phone, shop_customer.create...

Call count changes
ID        CALLS BEFORE  CALLS AFTER  RATIO  QUERY
--------  ------------  -----------  -----  ----------------------------------------------------------------------------------------------------------
ddb33523            42           83   2.0x  SELECT shop_product.id, shop_product.category_id, shop_product.sku, shop_product.name, shop_product.pri...

report

querylens report LOGS -o report.html [--schema schema.sql] [--limit 50] writes one HTML file with no external requests: summary tiles, top queries (click a header to sort, type to filter, expand to see the slowest example), N+1 findings, index advice and lints.

$ querylens report examples/django.log --schema examples/schema.sql -o report.html
wrote report.html (40 KiB, 16 query shapes)

Configuration

There are no config files. Behaviour is controlled by the flags above. Input format is detected from the first 64 KiB; override with --format. For logs where the request id has another shape, pass --request-id-regex 'trace=(\w+)'. PostgreSQL text logs need a prefix that starts with the timestamp and includes [pid] (for example log_line_prefix = '%m [%p] %q%u@%d ') for connection grouping; log_min_duration_statement = 0 logs every statement.

Architecture

Module Role
tokenizer.py SQL scanner, statement splitting, parameter substitution
fingerprint.py normalisation, literal extraction, stable hash
sketch.py DDSketch quantiles and histograms
parsers.py streaming parsers and format detection
aggregate.py Stats, Workload, analyze() pipeline
nplus1.py streaming run detector, parent selection, fix suggestions
sqlshape.py lightweight statement parser (tables, predicates, ordering, limits)
schema.py DDL parser (tables, columns, indexes)
advisor.py index candidates, dedupe, redundant indexes
lints.py anti-pattern checks
diff.py workload comparison
report.py HTML report
cli.py click commands and text tables

Design notes: docs/fingerprinting.md and docs/index-advisor.md.

Tests

.venv/bin/python -m pytest -q

44 tests cover tokenizer and fingerprint edge cases, every parser, sketch accuracy against numpy, N+1 windows, predicate extraction, index ordering and dedupe, lints, diff and CLI end to end on the example logs.

Limitations