Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Python

The buttercupql package compiles ButtercupQL from Python, and brings the bcql command with it. It is the Rust compiler itself, built for Python, so it compiles a query in microseconds: fast enough to compile on every request.

pip install buttercupql

The package is on PyPI. One wheel serves every Python from 3.9, on Linux, macOS and Windows.

Compiling and running a query

compile() gives the SQL and the values for its placeholders, ready for the database’s own driver:

import sqlite3
import buttercupql

db = sqlite3.connect(":memory:")
db.executescript("""
    CREATE TABLE cases (key TEXT, status TEXT, band TEXT, risk INTEGER);
    INSERT INTO cases VALUES ('C1', 'open', 'critical', 95), ('C2', 'open', 'low', 40),
                             ('C3', 'closed', 'high', 90), ('C4', 'open', 'high', NULL);
""")

query = buttercupql.compile("""
    from cases
    where status == "open" and band in ["critical", "high"]
    sort -risk
    select key, risk
""", dialect="sqlite")

assert query.sql == "SELECT key, risk\nFROM cases\nWHERE status = ? AND band IN (?, ?)\nORDER BY risk DESC"
assert query.params == ["open", "critical", "high"]
assert db.execute(query.sql, query.params).fetchall() == [("C1", 95), ("C4", None)]

A query indented inside a Python string reads as it is: the first line sets the column.

dialect is "postgres", the default, "sqlite", "duckdb" or "clickhouse". params chooses another style of placeholder: "format" writes %s, as psycopg takes them.

query = buttercupql.compile('from cases\nwhere status == "open"\n', params="format")
assert query.sql.endswith("WHERE status = %s")

Running on a database

run() compiles a text and runs it with the database’s own driver, and gives the rows of its query: a list of tuples, as fetchall() gives them, with the names of the columns in columns.

rows = buttercupql.run(db, """
    from cases
    where status == "open"
    sort -risk
    select key, risk
""")
assert rows == [("C1", 95), ("C2", 40), ("C4", None)]
assert rows.columns == ["key", "risk"]

The driver says which database it is, and which placeholders it takes: sqlite3; psycopg, psycopg2 or pg8000 for PostgreSQL; duckdb; or a clickhouse_connect client. For another DB-API driver, dialect= names the database. rows.to_pandas() gives the rows as a pandas DataFrame.

A text that changes the database runs statement by statement, in order, as one transaction: all of it, or none of it if a statement fails. ClickHouse has no transactions, so there each statement stands once it has run. The rows given are the last query’s:

rows = buttercupql.run(db, """
    create table archive:
        key: str = field(primary_key=True)
        risk: int | None

    from cases
    where status == "closed"
    select key, risk
    insert into archive

    from cases
    where status == "closed"
    delete

    from archive
""")
assert rows == [("C3", 90)]

query= runs a query def of the text, with its arguments given by name, as bind() takes them.

The database’s own tables

read_schema() reads the tables of the database a connection is to, with their columns and the links between them, for names to be checked against: a Schema for schema=.

schema = buttercupql.read_schema(db)
assert schema.tables == ["archive", "cases"]
try:
    buttercupql.run(db, "from archive\nsort -rsik\n", schema=schema)
except buttercupql.CompileError as error:
    assert error.diagnostics[0].label == "did you mean `risk`?"

It reads SQLite’s, DuckDB’s and PostgreSQL’s tables and views, and ClickHouse’s. In a notebook, the %%bcql magic reads them for each cell: see Notebooks.

Statements

A text compiles to one statement for a query, and to one for each change in a text that changes the database. statements gives each, to run one by one in order, with its SQL, its values and its kind: "query", "change", "definition" for a change to the database itself, and "begin" and "commit" around a with transaction: block. sql and params are all the statements together.

script = buttercupql.compile("""
    create table seen:
        key: str

    from cases
    where risk < 50
    update band = "low"
""", dialect="sqlite")
assert [statement.kind for statement in script.statements] == ["definition", "change"]
update = script.statements[1]
assert (update.sql, update.params) == ("UPDATE cases SET band = ?\nWHERE risk < 50", ["low"])

Mistakes

A text that does not compile raises buttercupql.CompileError, a ValueError. It holds every problem found, and its message shows each under its line, as bcql does:

try:
    buttercupql.compile('from cases\nwhere status = "open"\n', path="open.bcql")
except buttercupql.CompileError as error:
    [problem] = error.diagnostics
    assert (problem.severity, problem.line, problem.column) == ("error", 2, 14)
    assert problem.message == "use `==` to compare"
    assert str(error).startswith("error: use `==` to compare\n  ┌─ open.bcql:2:14\n")

check() gives the problems of a text without raising, and an empty list when there are none:

assert [p.message for p in buttercupql.check("from cases\nwehre risk > 90\n")] == ["there is no step `wehre`"]
assert buttercupql.check("from cases\nsort -risk\n") == []

Schemas

schema= checks every name against the database’s tables. It takes the CREATE TABLE statements, a buttercupql.Schema, or a dict of tables and their columns’ types:

schema = "CREATE TABLE cases (key text NOT NULL, status text NOT NULL, risk integer);"
try:
    buttercupql.compile('from cases\nwhere stauts == "open"\n', schema=schema)
except buttercupql.CompileError as error:
    assert error.diagnostics[0].label == "did you mean `status`?"

tables = {"cases": {"key": "text not null", "risk": "integer"}}
assert buttercupql.compile("from cases\nsort -key\n", schema=tables).sql.endswith("ORDER BY key DESC")

Queries with arguments

query= compiles a named query or a query def in place of the main query. A def’s parameters become placeholders, and bind() gives their values when the query runs, with the defaults for those not given:

text = """
def open_for(account: str, rows: int = 10):
    from cases
    where account_id == account
    take rows
"""
query = buttercupql.compile(text, dialect="sqlite", query="open_for")
assert query.sql == "SELECT *\nFROM cases\nWHERE account_id = ?\nLIMIT ?"
assert [(arg.name, arg.type, arg.default) for arg in query.args] == [("account", "str", None), ("rows", "int", 10)]
assert query.bind(account="A1") == ["A1", 10]
assert query.bind(account="A1", rows=3) == ["A1", 3]

The query can be compiled once and run with different arguments as often as needed. bind_named() gives the values by name, for named placeholders such as ClickHouse’s:

query = buttercupql.compile(text, dialect="clickhouse", query="open_for")
assert query.bind_named(account="A1") == {"account": "A1", "rows": 10}

A date or a time given to bind() is passed as text, in the form databases read: 2026-10-20 14:30:00.

Modules

A text that imports modules needs to know where they are. path= names the file the text is from, and its imports are read from files beside it; modules= gives the modules’ texts instead:

shared = "threshold = 1_000_000\ndef recent(days_back=7):\n    where raised > now() - days(days_back)\n"
query = buttercupql.compile(
    "from shared import recent, threshold\nfrom cases\nrecent(30)\nwhere amount > threshold\n",
    modules={"shared": shared},
)
assert query.sql == "SELECT *\nFROM cases\nWHERE raised > now() - INTERVAL '30 days' AND amount > 1000000"

The command

The package installs bcql, which python -m buttercupql also runs: see The bcql command.