Skip to main content

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, Snowflake COPY INTO / PUT / REMOVE, MySQL LOAD DATA / FLUSH, MSSQL BACKUP / RESTORE, ClickHouse OPTIMIZE.
  • Session / runtime state: SET, USE, BEGIN, COMMIT, ROLLBACK, LOCK, REINDEX, CLUSTER, Postgres LISTEN/NOTIFY, DuckDB ATTACH/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_only do 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pysqlast-0.1.0.tar.gz (24.1 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

pysqlast-0.1.0-cp38-abi3-win_amd64.whl (1.3 MB view details)

Uploaded CPython 3.8+Windows x86-64

pysqlast-0.1.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.2 MB view details)

Uploaded CPython 3.8+manylinux: glibc 2.17+ x86-64

pysqlast-0.1.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (1.2 MB view details)

Uploaded CPython 3.8+manylinux: glibc 2.17+ ARM64

pysqlast-0.1.0-cp38-abi3-macosx_11_0_arm64.whl (1.1 MB view details)

Uploaded CPython 3.8+macOS 11.0+ ARM64

pysqlast-0.1.0-cp38-abi3-macosx_10_12_x86_64.whl (1.2 MB view details)

Uploaded CPython 3.8+macOS 10.12+ x86-64

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

Hashes for pysqlast-0.1.0.tar.gz
Algorithm Hash digest
SHA256 c5ed71349f343f7c1e89338567a679b48bce5d084a21d5d93614f1b71692d899
MD5 c4a6918f75517923b90b7d3413d5e9bb
BLAKE2b-256 5d4b9bee26f8f86cb4cc30b307e32ea82c388faaa69782a85c69c078c2e7a4a7

See more details on using hashes here.

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

Hashes for pysqlast-0.1.0-cp38-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 dbe2811dddbc8bde4de9aae935bfd082e807b5a1d8feff92378c11d7934dd85c
MD5 d610a02cb25993f471f5776d4b3e268d
BLAKE2b-256 6ced9d01e9bc31bac2f48be2a7132a6c3703f4c5f5f4288d0cdc9540d0bb1ad3

See more details on using hashes here.

File details

Details for the file pysqlast-0.1.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for pysqlast-0.1.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 660911688934eac0264a720a38f0d5c48792ec53c48b07e470c357e2de0fd5ac
MD5 13c754d2ed56d15d074bc62abef04a1d
BLAKE2b-256 47784f1ba782b8a1aa9eabff457615ece44b1459ed62f06a0ff4aaefb8ab7fc5

See more details on using hashes here.

File details

Details for the file pysqlast-0.1.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for pysqlast-0.1.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 8a4701e2c784bba11f33c329900e37915c2c57c915758303aa23fd285e66808d
MD5 942a5cc7ad2cbbacf531860a697f96a1
BLAKE2b-256 9c645213d60af3d70035630ad77fdeea95a48bfd2e54217e365afdcf99579bad

See more details on using hashes here.

File details

Details for the file pysqlast-0.1.0-cp38-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for pysqlast-0.1.0-cp38-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 bcb1317d4d09a57df283528b35edee33f37bbb92cae681033e887c513be7e004
MD5 0036ce389a700dbdba14389b22fe4710
BLAKE2b-256 47b771df4a9f7b5c3c729e29bca10401426de51b36b4289807a9ae34ad43f1b3

See more details on using hashes here.

File details

Details for the file pysqlast-0.1.0-cp38-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for pysqlast-0.1.0-cp38-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 59a97f486d5d0caa1ebe3e26f85a4ae71a11117b2ad8f369b28f4399f612ee55
MD5 f7eaa1d78508dfff8d53a27be70864c3
BLAKE2b-256 0f1ada2ae6e20b26ec8c687e9b85997288c17d3c204a8a620c597388083fc7f4

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page