Query tables in storage
¶
This guide walks through querying tabular datasets stored in parquet and related file formats. Queries stream directly from disk or cloud storage with PyArrow, Polars or DuckDB.
import lamindb as ln
db = ln.DB("laminlabs/lamindata")
Show code cell output
! database has module pertdb, configure it: lamin settings modules set bionty,pertdb
Stream a table from storage¶
Start with a single parquet file. Get the artifact and open it — nothing is downloaded, you get a lazy pyarrow dataset:
artifact = db.Artifact.filter(
key__startswith="example_datasets/small", suffix=".parquet"
).first()
dataset = artifact.open()
dataset
Show code cell output
<pyarrow._dataset.FileSystemDataset at 0x7fec4d951a20>
Peek at the first few rows:
dataset.head(5).to_pandas()
Show code cell output
| CD8A | CD4 | CD38 | cell_medium | cell_type_by_model | cell_type_by_expert | sample_note | |
|---|---|---|---|---|---|---|---|
| sample4 | 2 | 3 | 4 | DMSO | B cell | NaN | None |
| sample5 | 3 | 4 | 2 | IFNG | T cell | NaN | None |
| sample6 | 3 | 5 | 3 | IFNG | T cell | NaN | None |
The same .open() call works across parquet files. Call it on a query to stream a whole set of Parquet artifacts as one dataset:
dataset = db.Artifact.filter(
key__startswith="example_datasets/small", suffix=".parquet", is_latest=True
).open()
dataset
Show code cell output
! this query set is unordered, consider using `.order_by()` first to avoid opening the artifacts in an arbitrary order
<pyarrow._dataset.FileSystemDataset at 0x7fec4d993d00>
Or on a collection, which streams all of its member Parquet files together:
collection = db.Collection.get(key="sharded_parquet_collection")
dataset = collection.open()
dataset.to_table().to_pandas()
Show code cell output
/opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
from .autonotebook import tqdm as notebook_tqdm
| cell_type | n_genes | percent_mito | |
|---|---|---|---|
| index | |||
| CGTTATACAGTACC-8 | CD4+/CD45RO+ Memory | 1034 | 0.010163 |
| AGATATTGACCACA-1 | CD4+/CD45RO+ Memory | 1078 | 0.012831 |
| GCAGGGCTGTATGC-8 | CD8+/CD45RA+ Naive Cytotoxic | 1055 | 0.012287 |
| TTATGGCTGGCAAG-2 | CD4+/CD25 T Reg | 1236 | 0.023963 |
| CACGACCTGGGAGT-7 | CD4+/CD25 T Reg | 1010 | 0.016620 |
| AATCTCACTCAGTG-3 | CD4+/CD45RO+ Memory | 1183 | 0.016056 |
| CTAGTTTGGCTTAG-4 | CD4+/CD45RO+ Memory | 1002 | 0.018922 |
| ACGCCGGAAGCCTA-6 | CD8+/CD45RA+ Naive Cytotoxic | 1292 | 0.018315 |
| CTGACCACCATGGT-4 | CD8+/CD45RA+ Naive Cytotoxic | 1559 | 0.024427 |
| AGTTAAACAAACAG-1 | CD19+ B | 1005 | 0.019806 |
| CTACGCACAGGGTG-3 | CD4+/CD45RO+ Memory | 1053 | 0.012073 |
| CAGACAACAAAACG-7 | CD4+/CD25 T Reg | 1109 | 0.012702 |
| GAGGGTGACCTATT-1 | CD4+/CD25 T Reg | 1003 | 0.012971 |
| TGACTGGAACCATG-7 | Dendritic cells | 1277 | 0.012961 |
| ACGACCCTGTCTGA-3 | Dendritic cells | 1074 | 0.017466 |
| GTTATGCTACCTCC-3 | CD14+ Monocytes | 1201 | 0.016839 |
| GTGTCAGATCTACT-6 | CD14+ Monocytes | 1014 | 0.025417 |
| AAGAACGAACTCTT-6 | CD14+ Monocytes | 1067 | 0.019530 |
| TACTCTGACGTAGT-1 | Dendritic cells | 1118 | 0.012069 |
| TAAGCTCTTCTGGA-4 | CD14+ Monocytes | 1059 | 0.021497 |
Queries¶
Filters push down into Parquet row groups — only matching data is read from storage, not the whole file.
import pyarrow.compute as pc
dataset.to_table(filter=pc.field("cell_type").is_valid()).to_pandas().head(5)
Show code cell output
| cell_type | n_genes | percent_mito | |
|---|---|---|---|
| index | |||
| CGTTATACAGTACC-8 | CD4+/CD45RO+ Memory | 1034 | 0.010163 |
| AGATATTGACCACA-1 | CD4+/CD45RO+ Memory | 1078 | 0.012831 |
| GCAGGGCTGTATGC-8 | CD8+/CD45RA+ Naive Cytotoxic | 1055 | 0.012287 |
| TTATGGCTGGCAAG-2 | CD4+/CD25 T Reg | 1236 | 0.023963 |
| CACGACCTGGGAGT-7 | CD4+/CD25 T Reg | 1010 | 0.016620 |
You can build up from there. Materialize the filtered result once, then compute against it in memory — for example, count rows per cell type without re-reading storage:
counts = (
dataset.to_table(filter=pc.field("cell_type").is_valid())
.group_by("cell_type")
.aggregate([("cell_type", "count")])
.to_pandas()
)
counts.head()
Show code cell output
| cell_type | cell_type_count | |
|---|---|---|
| 0 | CD4+/CD45RO+ Memory | 5 |
| 1 | CD8+/CD45RA+ Naive Cytotoxic | 3 |
| 2 | CD4+/CD25 T Reg | 4 |
| 3 | CD19+ B | 1 |
| 4 | Dendritic cells | 3 |
These filters run the same way on every engine below. For heavier patterns — schema evolution, time travel, appends, and concurrent writers — the LaminDB lakehouse benchmark walks through all six operations end to end across the five engines on a shared genomics dataset.
Choosing a query engine¶
By default Artifact.open() and Collection.open() use pyarrow to lazily open dataframes. polars can also be used by passing engine="polars". Note also that .open(engine="polars") returns a context manager with LazyFrame.
with collection.open(engine="polars") as lazy_df:
display(lazy_df.collect().to_pandas())
Show code cell output
| cell_type | n_genes | percent_mito | index | |
|---|---|---|---|---|
| 0 | CD4+/CD45RO+ Memory | 1034 | 0.010163 | CGTTATACAGTACC-8 |
| 1 | CD4+/CD45RO+ Memory | 1078 | 0.012831 | AGATATTGACCACA-1 |
| 2 | CD8+/CD45RA+ Naive Cytotoxic | 1055 | 0.012287 | GCAGGGCTGTATGC-8 |
| 3 | CD4+/CD25 T Reg | 1236 | 0.023963 | TTATGGCTGGCAAG-2 |
| 4 | CD4+/CD25 T Reg | 1010 | 0.016620 | CACGACCTGGGAGT-7 |
| 5 | CD4+/CD45RO+ Memory | 1183 | 0.016056 | AATCTCACTCAGTG-3 |
| 6 | CD4+/CD45RO+ Memory | 1002 | 0.018922 | CTAGTTTGGCTTAG-4 |
| 7 | CD8+/CD45RA+ Naive Cytotoxic | 1292 | 0.018315 | ACGCCGGAAGCCTA-6 |
| 8 | CD8+/CD45RA+ Naive Cytotoxic | 1559 | 0.024427 | CTGACCACCATGGT-4 |
| 9 | CD19+ B | 1005 | 0.019806 | AGTTAAACAAACAG-1 |
| 10 | CD4+/CD45RO+ Memory | 1053 | 0.012073 | CTACGCACAGGGTG-3 |
| 11 | CD4+/CD25 T Reg | 1109 | 0.012702 | CAGACAACAAAACG-7 |
| 12 | CD4+/CD25 T Reg | 1003 | 0.012971 | GAGGGTGACCTATT-1 |
| 13 | Dendritic cells | 1277 | 0.012961 | TGACTGGAACCATG-7 |
| 14 | Dendritic cells | 1074 | 0.017466 | ACGACCCTGTCTGA-3 |
| 15 | CD14+ Monocytes | 1201 | 0.016839 | GTTATGCTACCTCC-3 |
| 16 | CD14+ Monocytes | 1014 | 0.025417 | GTGTCAGATCTACT-6 |
| 17 | CD14+ Monocytes | 1067 | 0.019530 | AAGAACGAACTCTT-6 |
| 18 | Dendritic cells | 1118 | 0.012069 | TACTCTGACGTAGT-1 |
| 19 | CD14+ Monocytes | 1059 | 0.021497 | TAAGCTCTTCTGGA-4 |
LaminDB artifacts are plain Parquet files on S3, so any engine that reads Parquet works. DuckDB, Iceberg, and LanceDB are also supported — you pick based on your query pattern, not on how the data was stored.
.open() returns a lazy PyArrow dataset backed by S3. Filters push down into Parquet row groups.
import pyarrow.compute as pc
dataset = collection.open()
result = dataset.to_table(filter=pc.field("cell_type").is_valid()).to_pandas()
.open(engine="polars") returns a context manager yielding a Polars LazyFrame backed by S3. No data is read until .collect() is called.
import polars as pl
with collection.open(engine="polars") as lazy_df:
result = lazy_df.filter(pl.col("cell_type").is_not_null()).collect()
DuckDB reads Parquet files directly via read_parquet(). Use .cache() to get a local path, or register a view over S3 paths via httpfs for a collection:
import duckdb
con = duckdb.connect()
con.execute("INSTALL httpfs; LOAD httpfs;")
con.execute("CREATE OR REPLACE SECRET s3 (TYPE s3, PROVIDER credential_chain);")
s3_paths = [str(a.path) for a in collection.ordered_artifacts.all()]
con.execute(f"CREATE OR REPLACE VIEW data AS SELECT * FROM read_parquet({s3_paths})")
result = con.execute("SELECT * FROM data WHERE cell_type IS NOT NULL").df()
Iceberg requires a one-time ingestion into a catalog-managed table on S3. After ingestion you get native partition pruning, metadata-only schema evolution, and snapshot-based time travel.
from pyiceberg.catalog.sql import SqlCatalog
from pyiceberg.expressions import NotNull
arrow = collection.open().to_table()
catalog = SqlCatalog(
"local", uri="sqlite:///iceberg_catalog.db", warehouse="/tmp/iceberg_warehouse"
)
if not catalog.namespace_exists("demo"):
catalog.create_namespace("demo")
table = catalog.create_table("demo.data", schema=arrow.schema)
table.append(arrow)
result = table.scan(row_filter=NotNull("cell_type")).to_arrow().to_pandas()
LanceDB ingests data into Lance columnar format on S3 — the only engine here that copies data out of the source Parquet files. In exchange you get combined SQL filtering and vector search, plus versioned appends with time travel.
import lancedb
import pyarrow as pa
arrow = collection.open().to_table()
schema = pa.schema([
f.with_type(pa.string()) if pa.types.is_dictionary(f.type) else f
for f in arrow.schema
])
arrow = arrow.cast(schema)
db_lance = lancedb.connect("/tmp/lancedb_warehouse")
table = db_lance.create_table("data", data=arrow, mode="overwrite")
result = table.search().where("cell_type IS NOT NULL", prefilter=True).to_pandas()