Skip to main content
milvusql logo

milvusql

A PEP 249 DBAPI (sync + async) for Milvus — parses/generates MilvusQL via sqlglot-milvus and executes the resulting AST against pymilvus.

PyPI Python PyPI Downloads License CI

📚 Documentation · PyPI · sqlglot-milvus


Why a DBAPI, not a client wrapper?

Feature milvusql raw pymilvus
Query surface SQL (MilvusQL) Python method calls
Parameterized queries :name binds ⚠️ manual dict-building
Standard Connection/Cursor (PEP 249)
Sync + async, same dispatch table ⚠️ separate MilvusClient/AsyncMilvusClient
Drop-in for SQLAlchemy / Django milvusql-sqlalchemy, milvusql-django
Auto-LOAD on first use, cached per connection manual load_collection()
Consistency-level fallback (per-connection default, per-query override) manual per-call
JOIN / GROUP BY / subqueries / correlated EXISTS ✅ planned into one read per collection, combined client-side ❌ Milvus has none of them
Full-text search (BM25, MATCH ... AGAINST) ✅ one TEXT column + one generated SPARSEVEC ⚠️ schema Function + analyzer flags by hand
Reads past the 16384-row per-call ceiling ✅ transparent primary-key-cursor pages ⚠️ query_iterator (sync client only)
Introspection (SHOW TABLES, DESCRIBE) Python method calls

Writing MilvusQL instead of chaining pymilvus calls means the same SELECT ... ORDER BY embedding <=> :q LIMIT n string works whether it's typed by hand, generated by an ORM, or built by an LLM tool call — and it works the same way from cursor.execute() or await acursor.execute(), off one shared parser and dispatch table (translate.ast_to_pymilvus).

Installation

pip install milvusql

Quick start

import milvusql

conn = milvusql.connect(uri="./items.db")  # Milvus Lite, or a real server's URI
cur = conn.cursor()

cur.execute(
    """
    CREATE TABLE items (
        id BIGINT PRIMARY KEY AUTO_INCREMENT,
        embedding VECTOR(8),
        category VARCHAR(64)
    ) WITH (shards=1, consistency_level='Strong')
    """
)
cur.execute(
    "CREATE INDEX idx_embedding ON items (embedding) USING HNSW WITH (metric_type='COSINE')"
)
cur.executemany(
    "INSERT INTO items (embedding, category) VALUES (:embedding, :category)",
    [{"embedding": [0.1] * 8, "category": "book"}],
)

cur.execute("SELECT id FROM items WHERE category = :cat LIMIT 10", {"cat": "book"})
print(cur.fetchall())

cur.execute(
    "SELECT id FROM items ORDER BY embedding <=> :q LIMIT 5",
    {"q": [0.1] * 8},
)
print(cur.fetchall())

Full-text search is one column and one generated field away — Milvus's BM25 pipeline (analyzer, schema Function, sparse index) spelled as SQL:

cur.execute(
    """
    CREATE TABLE docs (
        id BIGINT PRIMARY KEY AUTO_INCREMENT,
        content TEXT,
        content_sparse SPARSEVEC GENERATED ALWAYS AS (BM25(content)),
        embedding VECTOR(768)
    )
    """
)
cur.execute(
    "CREATE INDEX idx_fts ON docs (content_sparse) "
    "USING SPARSE_INVERTED_INDEX WITH (metric_type='BM25')"
)

# keyword filter
cur.execute(
    "SELECT id, content FROM docs WHERE MATCH(content) AGAINST (:q)",
    {"q": "vector database"},
)
# BM25-ranked retrieval
cur.execute(
    "SELECT id, content FROM docs "
    "ORDER BY BM25_SCORE(content_sparse, :q) DESC LIMIT 10",
    {"q": "how do i tune hnsw"},
)
# dense + full-text hybrid, fused with RRF
cur.execute(
    """
    SELECT id, content FROM docs HYBRID SEARCH (
        embedding <=> :dv WEIGHT 0.6,
        BM25_SCORE(content_sparse, :q) WEIGHT 0.4
    ) RERANK RRF(k=60) LIMIT 10
    """,
    {"dv": query_embedding, "q": "how do i tune hnsw"},
)

And the surface a person (or an LLM agent) orients with:

SHOW TABLES;                 -- list_collections()
SHOW DATABASES;              -- list_databases()
DESCRIBE docs;               -- fields, types, keys, BM25 generators
CREATE DATABASE tenant_a;    USE tenant_a;    DROP DATABASE tenant_a;
DROP INDEX idx_fts ON docs;

The same program, asyncio-native, over milvusql.aio (built on pymilvus.AsyncMilvusClient):

from milvusql import aio

conn = aio.connect(uri="./items.db")
cur = conn.cursor()

await cur.execute("SELECT id FROM items WHERE category = :cat", {"cat": "book"})
async for row in cur:
    print(row)

await conn.close()

JOIN, GROUP BY and subqueries

Milvus reads one collection per RPC, joins nothing and reduces nothing. milvusql closes that gap without pretending it isn't there: a statement that needs more than one collection, a grouped aggregate or a subquery is planned into one Milvus read per collection, and the relational part is evaluated client-side with Polars.

cur.execute(
    """
    SELECT c.title, COUNT(*) AS n, AVG(i.price) AS avg_price
    FROM items AS i
    JOIN categories AS c ON i.cat_id = c.id
    WHERE i.price > :floor
    GROUP BY c.title
    HAVING COUNT(*) > 1
    ORDER BY n DESC
    LIMIT 10
    """,
    {"floor": 20.0},
)

What reaches Milvus, and what does not:

Pushed to Milvus Evaluated client-side
Every WHERE conjunct naming a single collection (i.price > 20) Predicates spanning two collections (i.price > c.budget)
The columns the statement actually references (projection pushdown) Joins (INNER/LEFT/RIGHT/FULL/CROSS, ON or USING)
ORDER BY <vector> <=> :q LIMIT k as a real ANN search GROUP BY, HAVING, aggregates, window functions
Equi-join keys learned from the previous read, as key in [...] WITH (CTEs), UNION/INTERSECT/EXCEPT, subqueries
Scalar ORDER BY, DISTINCT, LIMIT/OFFSET

Window functions are the one worth calling out against a vector database: ROW_NUMBER() OVER (PARTITION BY category ORDER BY distance) over a search's hits is top-k per group, which Milvus cannot express and an ANN index cannot answer directly.

SELECT * works across a join too, but it means what it says: each side is asked for output_fields=["*"], so every field of every collection comes back — vectors included. Naming the columns is the difference between moving a few scalars and moving every embedding.

The key pushdown is what keeps this usable: an ANN search returning 50 hits joined against a million-row collection reads 50 rows from it, not a million.

Three behaviours worth knowing before you rely on them:

  • Reads page past Milvus's 16384-row per-call ceiling. A scan that comes back at the ceiling continues with primary-key-cursor pages (the same iterator protocol pymilvus's own QueryIterator speaks), so joins, groupings, aggregates, client-side ORDER BY, UPDATE and a bare SELECT with no LIMIT all cover every matching row — on both the sync and async cursors. The one server that cannot serve ordered pages is Milvus Lite: there a result past the ceiling still raises NotSupportedError (never a silent truncation), same as before. No snapshot spans the pages; rows written mid-read may or may not appear, exactly as with pymilvus's own iterator.
  • ORDER BY <vector> ... LIMIT k in a joined query means "Milvus's top-k from that collection, then join" — not "top-k of the joined result". The two differ only when the join or a cross-collection predicate drops rows; ranking after the join would mean reading the whole vector collection, which is the one thing an ANN index exists to avoid.
  • Columns must be unambiguous. With more than one collection in scope, a bare id raises ProgrammingError asking you to qualify it: output fields are requested from Milvus before any row comes back, and no collection schema is available at that point to resolve the name against.

Through the ORMs

Both packages inherit this at the DBAPI level — no ORM-side change was needed — so the ordinary constructs compile and run:

SQLAlchemy Django
select(...).join(...) / .outerjoin(...) filter(related__field=...), select_related(...)
.group_by(...) / .having(...) .values(...).annotate(Count(...)), then .filter(n__gt=...)
col.in_(select(...)) filter(fk__in=Model.objects.values("id"))
Query.count() (still a server-side count(*), no rows fetched) .count()

Django's compiler groups and orders by ordinal position (GROUP BY 1, ORDER BY 2 DESC) rather than by name, and both ORMs quote every identifier; the planner resolves both spellings.

Correlated [NOT] EXISTS — what SQLAlchemy's .any()/.has() and Django's Exists(... OuterRef(...)) compile to — is decorrelated into a semi/anti join (the rewrite SQL engines perform for the same shape), so those ORM constructs run. The correlation must be an equality; SQL's own NULL semantics are kept (a null key never matches EXISTS, always survives NOT EXISTS).

Not supported, and rejected explicitly rather than mistranslated:

  • Correlated subqueries beyond EXISTS equality — Django's Subquery(...) annotations (a correlated value per outer row) and non-equi EXISTS correlations. The error names the construct.
  • WITH RECURSIVE (re-reads until a fixpoint), INTERSECT ALL / EXCEPT ALL (duplicate-count semantics a semi/anti join cannot express), window frame clauses (ROWS/RANGE BETWEEN), LAG/ LEAD/NTILE, JOIN ... USING past two sources, and SELECT * inside a subquery that joins — the last two because two collections can own the same column name, and nothing at translate time says which.

Anything that doesn't need this path — a filter SELECT, a vector search, a hybrid search, a bare COUNT(*) — is still exactly one RPC and never builds a DataFrame.

API

milvusql.connect()

milvusql.connect(
    uri="http://localhost:19530",  # or a Milvus Lite file path
    token="",                      # "user:password", or a full token string
    db_name="",
    consistency_level=None,        # per-connection default; a query's own CONSISTENCY LEVEL wins
    **kwargs,                      # passed straight through to pymilvus.MilvusClient
) -> Connection
Connection Description
.cursor() Returns a new Cursor bound to this connection
.commit() No-op — every statement is already applied when it returns
.rollback() Raises NotSupportedError — Milvus has no multi-statement rollback; catch and compensate instead
.close() Closes the underlying MilvusClient
Context manager with milvusql.connect(...) as conn: ...
Cursor Description
.execute(operation, parameters=None) Runs one statement; parameters binds :name placeholders
.executemany(operation, seq_of_parameters) Batched INSERT in one round trip where the statement allows it; falls back to one call per parameter set otherwise
.fetchone() / .fetchmany(size) / .fetchall() Read back result rows
.description, .rowcount, .lastrowid, .arraysize Standard PEP 249 attributes
Iteration for row in cursor: ...

milvusql.aio.connect()/AsyncConnection/AsyncCursor mirror the same shape, async/await throughout — deliberately not PEP 249 itself (execute() as a coroutine can't be), but built on the same parser, dispatch table, and error hierarchy as the sync path.

Column types

MilvusQL Milvus field type Notes
BIGINT / INT / SMALLINT / TINYINT INT64/32/16/8 PRIMARY KEY [AUTO_INCREMENT] on BIGINT/VARCHAR
FLOAT / DOUBLE / BOOLEAN / JSON ditto JSON paths filter server-side: WHERE meta['brand'] = :b
VARCHAR(n) VARCHAR
TEXT analyzer-enabled VARCHAR(65535) full-text input: MATCH ... AGAINST + BM25
ARRAY<T>(capacity) ARRAY ARRAY_CONTAINS/_ALL/_ANY, ARRAY_LENGTH filter server-side
VECTOR(dim) FLOAT_VECTOR
SPARSEVEC SPARSE_FLOAT_VECTOR GENERATED ALWAYS AS (BM25(text_col)) for full-text
BINARYVEC(dim) / FLOAT16VEC(dim) / BFLOAT16VEC(dim) / INT8VEC(dim) BINARY/FLOAT16/BFLOAT16/INT8_VECTOR bind values as bytes / numpy arrays, passed through untouched

Errors

Standard PEP 249 hierarchy, importable from milvusql:

Warning
Error
├── InterfaceError
└── DatabaseError
    ├── DataError
    ├── OperationalError
    ├── IntegrityError
    ├── InternalError
    ├── ProgrammingError
    └── NotSupportedError

Every pymilvus exception and gRPC error raised while executing a statement is translated into one of these before it reaches your code.

Packages

This is the core of a uv workspace. Two packages build on milvusql's DBAPI:

Package Description
milvusql-sqlalchemy SQLAlchemy 2.0 dialect — VECTOR/SPARSEVEC column types, hybrid_search(), Alembic support
milvusql-django Django database backend — VectorField, ORM CRUD/filtering through the normal compiler

Each is installed and versioned separately; both depend on this package as their DBAPI layer.

Examples

Example Shows
examples/basic_walkthrough A guided, top-to-bottom tour of the DBAPI: connect, CREATE TABLE/CREATE INDEX, insert, filter SELECT, vector search, UPDATE/DELETE — sync and async
examples/temporal_worker A Temporal workflow/activity that inserts rows into Milvus as a durable, retry-safe ingestion pipeline

See also milvusql-sqlalchemy's own examples (a FastAPI image-search service, a pydantic-ai agent).

Development

Requires Python 3.12+, uv, task.

task install           # uv sync --all-groups --all-packages
task lint              # ruff + ty + bandit for core + all packages
task tests             # all tests (core + sqlalchemy + django) -- integration tests need Docker (testcontainers)

Individual package tasks:

task core:lint         task core:test
task sqlalchemy:lint   task sqlalchemy:test
task django:lint       task django:test

See benchmarks/ for what the planner's key/predicate pushdown buys, measured through the public DBAPI, and CONTRIBUTING.md for ground rules.

License

MIT

Release files for milvusql 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for milvusql 1.0.0
File Size Uploaded
milvusql-1.0.0.tar.gz 70.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for milvusql 1.0.0
File Interpreter ABI Platform
milvusql-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 148.0 kB

Release files / milvusql-1.0.0.tar.gz

Download URL milvusql-1.0.0.tar.gz
Size 70.4 kB
Tags Source
SHA-256 checksum
How to use checksums
445aef7fa75e4527f8fef1c24ec6af35e0507220fcd4dc3c101ad16f68b52dd0
BLAKE2b-256 checksum
How to use checksums
71a7ddbb03908b2c3c4c2bbb441970f912416246b0bb36b677a72f0f7064f979
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / milvusql-1.0.0-py3-none-any.whl

Download URL milvusql-1.0.0-py3-none-any.whl
Size 77.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f56ff6257d606771e68e68699806f76bc9ea042346635c9991a8650278b3e8cd
BLAKE2b-256 checksum
How to use checksums
f4d053318ec754c9b1d285cfbb08abe323f52d6e7788a1b6a4ea1f48a419f48c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page