Fast, friendly Python SQL AST powered by sqlparser-rs
Project description
pysqlast
Fast, friendly Python SQL AST — powered by
sqlparser-rs.
pysqlast is a thin, ergonomic Python binding around the battle-tested
Rust sqlparser crate used by Apache DataFusion. It is designed for
read-only SQL tooling — linters, query analyzers, lineage extractors,
and especially MCP tools that need to safely inspect SQL without
executing it.
- Parses 13 SQL dialects (Postgres, MySQL, SQLite, Snowflake, BigQuery, Redshift, MsSQL, Hive, ClickHouse, DuckDB, Databricks, ANSI, Generic).
- Returns a plain dict/list AST — no custom classes to learn, serialize directly to JSON.
- High-level helpers for the 90% case:
tables(),columns(),statement_type(),is_read_only(). - Rust speed, Python ergonomics. abi3 wheels for Python ≥ 3.8.
Install
uv add pysqlast
pip install pysqlast
Pre-built wheels are published for Linux (x86_64, aarch64), macOS (x86_64, aarch64), and Windows (x64). No Rust toolchain required to install.
Usage
Parse a statement
import pysqlast
ast = pysqlast.parse_one(
"SELECT u.id, u.name FROM users u WHERE u.active",
dialect="postgres",
)
# ast is a plain dict mirroring the sqlparser AST:
# {'Query': {'body': {'Select': {...}}, ...}}
parse(sql) returns a list of statements; parse_one(sql) insists
on exactly one statement and returns it directly.
Extract tables and columns
sql = """
SELECT u.id, o.total
FROM analytics.users u
JOIN orders o ON o.uid = u.id
WHERE u.created_at > now() - interval '7 days'
"""
pysqlast.tables(sql, dialect="postgres")
# ['analytics.users', 'orders']
pysqlast.columns(sql, dialect="postgres")
# ['u.id', 'o.total', 'o.uid', 'u.created_at']
Classify and guard read-only
Great for MCP tools that should never let an LLM execute a mutation:
pysqlast.statement_type("SELECT 1; UPDATE t SET x=1")
# ['SELECT', 'UPDATE']
pysqlast.is_read_only("SELECT * FROM users") # True
pysqlast.is_read_only("EXPLAIN SELECT * FROM users") # True
pysqlast.is_read_only("DELETE FROM users") # False
def guard(sql: str) -> None:
if not pysqlast.is_read_only(sql, dialect="postgres"):
raise PermissionError("only read-only SQL is allowed here")
Safety model
is_read_only is deny-by-default. The top-level statement must be
one of an explicit allowlist — Query (SELECT/WITH/UNION), Explain,
ExplainTable, or Show*. Anything else is refused, including:
- DML/DDL:
INSERT,UPDATE,DELETE,MERGE,TRUNCATE,CREATE *,ALTER *,DROP *,GRANT,REVOKE. - Bulk data movement: Redshift
UNLOAD/COPY/VACUUM/ANALYZE, SnowflakeCOPY INTO/PUT/REMOVE, MySQLLOAD DATA/FLUSH, MSSQLBACKUP/RESTORE, ClickHouseOPTIMIZE. - Session / runtime state:
SET,USE,BEGIN,COMMIT,ROLLBACK,LOCK,REINDEX,CLUSTER, PostgresLISTEN/NOTIFY, DuckDBATTACH/INSTALL/LOAD. - Indirect execution:
PREPARE,EXECUTE,CALL,DEALLOCATE— these run code we can't statically inspect, so we treat them as unsafe.
For statements that are in the allowlist, the AST is then walked to catch data-modifying CTEs (the only way a write can hide inside a SELECT):
-- Refused: nested INSERT inside a CTE.
WITH ins AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * FROM ins;
Multi-statement input is read-only only if every statement is.
Parse failures raise ValueError — an MCP guard should treat that as
refusal as well.
Limitations. Static SQL analysis can't see what user-defined
functions or stored procedures do; SELECT my_udf_that_writes() will
be classified as read-only because the parser sees only a function
call. If your environment exposes such functions, deny them at a layer
below this one (e.g. role-based DB permissions).
Walk the AST
ast = pysqlast.parse_one("SELECT a, b FROM t WHERE a > 1")
# Every literal in the tree:
literals = pysqlast.find_all(
ast, lambda n: isinstance(n, dict) and "Value" in n
)
pysqlast.walk(node) yields every nested node depth-first.
pysqlast.find(node, pred) returns the first match;
pysqlast.find_all(node, pred) returns every match.
Dialects
Pass any of these strings as dialect=:
generic ansi postgres mysql sqlite mssql snowflake
bigquery redshift hive clickhouse duckdb databricks
Common aliases work too: pg, postgresql, my, ms, sqlserver,
tsql, bq, gbq, sf, rs, ch, duck, standard. Dialect
strings are case-insensitive. See pysqlast.supported_dialects().
Performance
pysqlast calls into the same Rust parser used by Apache DataFusion. The
hot path is fully in Rust; Python overhead is limited to one
serialization of the AST to a Python dict per call (via pythonize).
Measured on a realistic query (CTE + 3 joins + WHERE/ORDER BY/LIMIT), Apple M-series, Python 3.14, Postgres dialect:
| Operation | Time / call |
|---|---|
tables() |
~27 µs |
columns() |
~28 µs |
statement_type() / is_read_only() |
~26 µs |
parse_one() (full dict AST) |
~46 µs |
That's roughly 20k–35k SQL statements per second per core on a single-threaded Python loop. Bigger queries scale roughly linearly with their length.
Notes:
tables,columns,statement_type,is_read_onlydo not materialize the Python AST — they walk the Rust AST directly and return only primitives. Prefer these when you don't need the full tree.- If you call
parse()and then walk it repeatedly in Python, cache the result; the dict tree is the expensive part, not the parse itself. - The parser is allocation-heavy but single-threaded; the GIL is held for the duration of each call, so concurrency wins come from multiprocessing, not threads.
Why dicts instead of typed classes?
The Rust AST has hundreds of variants and evolves with each
sqlparser release. A typed Python mirror would either be a massive
hand-written wrapper (brittle) or auto-generated (still brittle, and
hard to keep ergonomic). Returning the serde JSON shape keeps the
binding small, lets you json.dumps(ast) for free, and pairs naturally
with the walk / find helpers when you do need to look inside.
If you want types, the shape is documented by the
sqlparser Rust source;
each Python dict key matches a Rust enum variant or struct field.
Development
Requires a Rust toolchain and Python ≥ 3.8.
pip install maturin pytest
maturin develop --release
pytest
To build wheels locally:
maturin build --release --strip
A GitHub Actions workflow (.github/workflows/release.yml) builds
manylinux / macOS / Windows wheels and publishes to PyPI via
trusted publishing when
you push a v* tag.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pysqlast-0.1.0.tar.gz.
File metadata
- Download URL: pysqlast-0.1.0.tar.gz
- Upload date:
- Size: 24.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: maturin/1.13.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c5ed71349f343f7c1e89338567a679b48bce5d084a21d5d93614f1b71692d899
|
|
| MD5 |
c4a6918f75517923b90b7d3413d5e9bb
|
|
| BLAKE2b-256 |
5d4b9bee26f8f86cb4cc30b307e32ea82c388faaa69782a85c69c078c2e7a4a7
|
File details
Details for the file pysqlast-0.1.0-cp38-abi3-win_amd64.whl.
File metadata
- Download URL: pysqlast-0.1.0-cp38-abi3-win_amd64.whl
- Upload date:
- Size: 1.3 MB
- Tags: CPython 3.8+, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: maturin/1.13.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dbe2811dddbc8bde4de9aae935bfd082e807b5a1d8feff92378c11d7934dd85c
|
|
| MD5 |
d610a02cb25993f471f5776d4b3e268d
|
|
| BLAKE2b-256 |
6ced9d01e9bc31bac2f48be2a7132a6c3703f4c5f5f4288d0cdc9540d0bb1ad3
|
File details
Details for the file pysqlast-0.1.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: pysqlast-0.1.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 1.2 MB
- Tags: CPython 3.8+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: maturin/1.13.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
660911688934eac0264a720a38f0d5c48792ec53c48b07e470c357e2de0fd5ac
|
|
| MD5 |
13c754d2ed56d15d074bc62abef04a1d
|
|
| BLAKE2b-256 |
47784f1ba782b8a1aa9eabff457615ece44b1459ed62f06a0ff4aaefb8ab7fc5
|
File details
Details for the file pysqlast-0.1.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: pysqlast-0.1.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 1.2 MB
- Tags: CPython 3.8+, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: maturin/1.13.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8a4701e2c784bba11f33c329900e37915c2c57c915758303aa23fd285e66808d
|
|
| MD5 |
942a5cc7ad2cbbacf531860a697f96a1
|
|
| BLAKE2b-256 |
9c645213d60af3d70035630ad77fdeea95a48bfd2e54217e365afdcf99579bad
|
File details
Details for the file pysqlast-0.1.0-cp38-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: pysqlast-0.1.0-cp38-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 1.1 MB
- Tags: CPython 3.8+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: maturin/1.13.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bcb1317d4d09a57df283528b35edee33f37bbb92cae681033e887c513be7e004
|
|
| MD5 |
0036ce389a700dbdba14389b22fe4710
|
|
| BLAKE2b-256 |
47b771df4a9f7b5c3c729e29bca10401426de51b36b4289807a9ae34ad43f1b3
|
File details
Details for the file pysqlast-0.1.0-cp38-abi3-macosx_10_12_x86_64.whl.
File metadata
- Download URL: pysqlast-0.1.0-cp38-abi3-macosx_10_12_x86_64.whl
- Upload date:
- Size: 1.2 MB
- Tags: CPython 3.8+, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: maturin/1.13.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
59a97f486d5d0caa1ebe3e26f85a4ae71a11117b2ad8f369b28f4399f612ee55
|
|
| MD5 |
f7eaa1d78508dfff8d53a27be70864c3
|
|
| BLAKE2b-256 |
0f1ada2ae6e20b26ec8c687e9b85997288c17d3c204a8a620c597388083fc7f4
|