sqlparse
sqlparse turns a blob of SQL text into a tree of tokens you can walk, reformat, or split apart. Three module-level functions cover nearly every use: split() cuts a script into individual statements while respecting strings, comments, and semicolons inside blocks; format() pretty-prints with options like reindent, keyword_case, and strip_comments; and parse() returns Statement objects whose tokens are either leaves with a ttype or groups such as Identifier, IdentifierList, Where, Function, and Parenthesis that you can descend into. The critical word in the project description is non-validating. It accepts any input, never raises on bad SQL, and assumes no particular dialect, which is why templated and vendor-specific SQL parses at all. It is pure Python with zero dependencies and also installs a sqlformat command line tool.
The right size of tool for splitting scripts, pretty-printing SQL, and shallow introspection, which is why it sits under Django and half the Python data tooling. Do not mistake the token tree for an AST, and keep it away from untrusted input without a size cap and a timeout.
Use it if
- You need to split a .sql script into executable statements and a naive split on semicolons would break on semicolons inside string literals, comments, or BEGIN blocks
- You want readable SQL in a log line, an admin page, or an error message: format(sql, reindent=True, keyword_case='upper') is a one-liner and this is exactly what the Django debug toolbar does
- You need light introspection of arbitrary SQL: what type of statement is this, which identifiers and aliases appear, what is in the WHERE clause, without committing to a dialect or a grammar
- You want a dependency-free pure Python package that installs anywhere, including locked-down environments where a Rust or C extension is not an option
- You want a pre-commit hook that reformats .sql files in place, which the repository ships as the sqlformat hook
- You want to know whether SQL is valid. It is a non-validating parser by design: 'this is not sql at all ((' parses without complaint and returns a statement whose get_type() is 'UNKNOWN'. There is no error path to check, so a typo in your input silently becomes a weird token tree
- You need a real AST with dialect awareness. sqlparse groups tokens with heuristics, not a grammar, so it has no concept of scope, no resolved column references, and no way to rewrite a query safely. sqlglot parses into a typed AST and transpiles between more than twenty dialects
- You are enforcing SQL style in CI. format() is a pretty-printer with a handful of switches, not a linter: no rule configuration, no diagnostics, no autofix report. sqlfluff exists for that job
- You are anywhere near untrusted input. This parser has a repeated history of CPU exhaustion reports, and the current development branch fixes two more quadratic behaviours in token grouping (CWE-1333) where a roughly 2 KB payload could pin a worker for over ten seconds. A grouping depth cap now raises SQLParseError at 100 levels, but you still want an input size bound and a timeout around every call
- You think it makes SQL safe. It does not detect or neutralise injection, and splitting a string into statements before executing them is not a security control. Use parameterised queries
- You need throughput. Pure Python tokenisation over a multi-megabyte dump is slow enough that people notice, and there is no streaming API beyond parsestream, which still builds a full tree per statement
- You need a responsive tracker. There are 241 open issues (272 counting PRs), and the README says plainly that the project is maintained in spare time and a reply can take a while
Setup reality
pip install sqlparse is about as painless as Python packaging gets: pure Python, no dependencies, wheels for everything, and Python 3.8 or newer for the current 0.5.5 release. The development branch raises that floor to 3.10, so pin if you are on an older interpreter. Two things surprise people after install. First, the package name collides with expectations from ORMs: Django already depends on sqlparse for sqlmigrate and debug output, so it is probably in your environment whether you asked for it or not, and pinning a version can conflict with Django's own range. Second, the mental model. parse() returns a tuple of statements, not one statement, so the [0] you see everywhere is not optional. Tokens come in two kinds: leaves carry a ttype from sqlparse.tokens and groups carry None and hold children, and almost every walk you write needs to handle both plus whitespace tokens that appear between everything. Whitespace is preserved, so any comparison against str(token) has to account for it or use token.normalized. Installing also puts a sqlformat script on PATH, which reads stdin when the filename is '-' and can rewrite files with --in-place.
Patterns
Cut a multi-statement script into statementssplit-a-script
import sqlparse
sqlparse.split("select * from foo; select * from bar;")
# ['select * from foo;', 'select * from bar;']
sqlparse.split("select 1; select 2;", strip_semicolon=True)
# ['select 1', 'select 2']This is the main reason to install the library: it knows a semicolon inside a string literal or a comment is not a separator. Pass strip_semicolon=True when you are handing each statement to a driver that rejects trailing semicolons.
Pretty-print a queryformat-sql
print(sqlparse.format(
"select id,name from users where active=1 and x in (1,2)",
reindent=True,
keyword_case="upper",
indent_width=4,
))
# SELECT id,
# name
# FROM users
# WHERE active=1
# AND x IN (1,
# 2)reindent implies strip_whitespace, so the input's own line breaks are discarded. Useful options beyond these: identifier_case, strip_comments, comma_first, wrap_after, reindent_aligned, and compact. An invalid option value raises SQLParseError, which is the only place format() throws.
Inspect what a statement is made ofwalk-the-token-tree
statement = sqlparse.parse("select id, name from users where active = 1")[0]
print(statement.get_type()) # 'SELECT'
for token in statement.tokens:
if token.is_whitespace:
continue
print(f"{token.ttype or type(token).__name__!s:20} {token}")
# Token.Keyword.DML select
# IdentifierList id, name
# Token.Keyword from
# Identifier users
# Where where active = 1parse() returns a tuple, hence the [0]. A token with a ttype is a leaf; a token whose ttype is None is a group you can recurse into via token.tokens. Whitespace is preserved as real tokens, so skip it explicitly in every loop.
Pull names, aliases, and qualifiers out of a select listread-identifiers-and-aliases
from sqlparse.sql import IdentifierList
sql = "select a.id, b.name as bn from users a join orgs b on a.oid = b.id"
statement = sqlparse.parse(sql)[0]
for token in statement.tokens:
if isinstance(token, IdentifierList):
for ident in token.get_identifiers():
print(ident.get_real_name(), ident.get_alias(), ident.get_parent_name())
# id None a
# name bn bget_name() returns the alias when there is one and the real name otherwise, which is usually what you want for output columns. A single-column select produces a bare Identifier rather than an IdentifierList, so handle both types or you will silently skip those queries.
Collect the tables a query reads fromextract-table-names
from sqlparse.sql import Identifier, IdentifierList
from sqlparse.tokens import Keyword
SOURCES = {"FROM", "JOIN", "INNER JOIN", "LEFT JOIN", "LEFT OUTER JOIN"}
def table_names(statement):
names, expecting = [], False
for token in statement.tokens:
if token.is_whitespace:
continue
if expecting:
if isinstance(token, IdentifierList):
names += [i.get_real_name() for i in token.get_identifiers()]
elif isinstance(token, Identifier):
names.append(token.get_real_name())
expecting = False
elif token.ttype is Keyword and token.normalized in SOURCES:
expecting = True
return names
table_names(sqlparse.parse("select * from public.users u join orgs o on 1=1")[0])
# ['users', 'orgs']This walks the top level only, so subqueries, CTEs, and derived tables are missed unless you recurse into group tokens. There is no built-in table extractor and every version of this snippet on the internet has holes; if you need it to be right, use sql-metadata or sqlglot.
Read comparisons out of a WHERE clauseinspect-the-where-clause
from sqlparse.sql import Where, Comparison
statement = sqlparse.parse("select * from t where a = 1 and b > 2")[0]
where = next(t for t in statement.tokens if isinstance(t, Where))
for token in where.tokens:
if isinstance(token, Comparison):
print(token.left, token.right)
# a 1
# b 2Comparison exposes .left and .right but not the operator as an attribute; read it from the tokens between them. OR branches, NOT, and parenthesised groups are all just more nested tokens, so anything past a flat AND list needs recursion.
Iterate every leaf token, ignoring the treeflatten-tokens
from sqlparse.tokens import Keyword, DML
statement = sqlparse.parse("select a.id from t join u on 1=1")[0]
keywords = [str(t) for t in statement.flatten() if t.ttype in (Keyword, DML)]
# ['select', 'from', 'join', 'on']flatten() is the right tool for token-type counting and simple redaction. It is the wrong tool for names, because a qualified identifier like public.users flattens into three tokens: 'public', '.', 'users'.
Remove comments before logging or hashing a querystrip-comments
sqlparse.format(
"select 1 -- inline note\n, 2 /* block */ from t",
strip_comments=True,
strip_whitespace=True,
)
# 'select 1 , 2 from t'Handy for grouping query shapes in a slow-query log, since ORMs and BI tools inject per-request comments that otherwise make every query unique. It removes the comment text but not the space it occupied, so normalise whitespace too.
Shorten long string literals before storing a querytruncate-literals
sqlparse.format(
"insert into t values ('averylongstring')",
truncate_strings=8,
)
# "insert into t values ('averylon[...]')"This is the closest thing to redaction the library offers, and it is not one: it shortens literals, it does not identify sensitive ones, and numbers are untouched. Set truncate_char to change the '[...]' marker.
Remember that nothing raises for bad SQLhandle-invalid-input
statement = sqlparse.parse("this is not sql at all ((")[0]
print(statement.get_type()) # 'UNKNOWN'
print(repr(sqlparse.format("select from where", reindent=True)))
# 'select\nfrom\nwhere'get_type() returning 'UNKNOWN' is the only signal you get, and plenty of genuinely broken SQL still returns 'SELECT'. If your pipeline needs validation, run the statement through the target database's parser or use sqlglot, which raises on syntax it cannot parse.
Bound the work when input is not yoursguard-against-dos
from sqlparse.exceptions import SQLParseError
MAX_BYTES = 64 * 1024
def safe_parse(sql):
if len(sql) > MAX_BYTES:
raise ValueError("query too large to parse")
try:
return sqlparse.parse(sql)
except SQLParseError as exc:
# e.g. 'Maximum grouping depth exceeded (100).'
raise ValueError(f"unparseable: {exc}") from excThe depth cap is a backstop against deeply nested parentheses, not a time limit. Wide statements and the quadratic grouping paths fixed on the development branch are not covered by it, so keep the size bound and run parsing off the request thread if the source is public.
Format files from the shell or a pre-commit hookuse-the-cli
sqlformat --reindent --keywords upper query.sql
cat query.sql | sqlformat --reindent -
# .pre-commit-config.yaml
# repos:
# - repo: https://github.com/andialbrecht/sqlparse
# rev: 0.5.5
# hooks:
# - id: sqlformat
# args: [--in-place, --reindent, --keywords, upper]The hook defaults to --in-place --reindent. If you override args, keep --in-place or the hook writes to stdout and leaves your files exactly as they were, which looks like a passing hook that did nothing.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| sqlglot | PyPI | You need a real AST, dialect-aware parsing, query rewriting, or transpilation between engines. |
| sqlfluff | PyPI | You want a configurable SQL linter and formatter to run in CI with rules and autofix. |
| sql-metadata | PyPI | You only want table names, column names, and aliases out of a query and do not want to write the token walk yourself. |