Skip to main content
Applies to: Embedded ✅, Enterprise ✅ A table stores data in rows and columns and keeps a history of committed versions. Queries read table data, writes update it, and indexes accelerate searches over its columns. This page covers table management, data types, schema changes and version history. For loading records, see Ingestion. For changes to columns, see Schema evolution.
In LanceDB Enterprise, tables are also created and dropped in SQL with CREATE TABLE and DROP TABLE.

Table structure and metadata

Create a table

data can be a list of objects, an Arrow table or reader, a pandas or polars frame, or Pydantic models. Ingesting data covers each, plus exist_ok and mode="overwrite" for re-runnable scripts.
An explicit schema is where nullability is decided. In the embedded library, creation is also the only point at which a table can be given stable row ids; in LanceDB Enterprise, whether a new table gets them is the deployment’s setting. See Limits.

Open and list tables

Each accepts namespace_path to act inside a namespace rather than at the database root. For Python remote connections created with lancedb.connect("db://..."), use db.table_exists(["documents"]) to check whether a table exists. For an async remote connection, use await db.table_exists(["documents"]). Include namespace segments before the table name, for example ["analytics", "documents"]. These checks return False when the table is missing and propagate other errors, including permission errors. Local synchronous directory connections still raise NotImplementedError for table_exists. On a connection from connect_namespace("dir", ...) or connect_namespace("rest", ...), it fails with an AttributeError. For these connections, open the table and handle the error, or look for its name in a listing. A missing table raises ValueError on a local directory and lance_namespace.TableNotFoundError on a connection from connect_namespace. A listing returns one page of names at a time, so check every page. TypeScript has no tableExists.

Rename and clone tables

Renaming is not supported on a local directory connection. In LanceDB Enterprise, whether a table can be renamed or cloned at all depends on how the deployment stores its catalog. A shallow clone (the default) references the source’s data files rather than copying them, so it is fast and cheap regardless of table size. It is the practical way to branch an experiment off a known version, or to pin a training set to the exact version a model saw.
A shallow clone depends on the source’s files continuing to exist. If the source is compacted past the retention window, the version the clone points at can be pruned. LanceDB Embedded connections support shallow clones only; is_shallow=False is not implemented. If the copy must outlive the source’s history, copy its data into an independent table instead. A deployment that supports cloning also makes shallow clones only, and one whose catalog does not support it refuses every clone.
Enterprise job type: on an Enterprise cluster that drops tables in the background, the teardown runs as a job: drop_table, which fences the table’s directory with a marker and deletes its content, or drop_object, depending on how the deployment stores its catalog. See Object teardown.

Drop a table

Dropping removes the table and every version of it. Versioning does not survive a drop, and neither drop_all_tables nor drop_database asks for confirmation. Both work only on a local database; on an Enterprise connection the SDK raises instead.
On a large table the drop can take a while, so drop_table_async returns a job rather than blocking when the deployment drops tables in the background. In the embedded library, and on an Enterprise cluster that drops inline, the returned job has already finished.

Table management APIs

Data types

A table’s schema is an Arrow schema: there is no separate LanceDB type system mapped onto storage, so a column is the same object to the query engine as it is to the format. A vector column is a fixed_size_list<float32, dim>, an ordinary Arrow type, which is why it is selectable and joinable but why arithmetic over it is list arithmetic rather than scalar. See SQL data types for the full reference, including the extension types and pseudo-columns.

Multimodal data

Store images, audio, video, and documents alongside structured columns and embeddings in the same versioned table. Use blob columns for these payloads. Small values stay inline in the data file, up to 64 KiB by default, and larger values are stored outside it.

Blob columns

Early access A blob column keeps metadata separate from the payload bytes, so queries can filter rows without loading the blobs. Most queries return descriptors, which contain metadata about the blob instead of its bytes. You can read the bytes in the query results or fetch them by row ID. In Python, plain to_pandas() scans can also return BlobFile handles when pylance is installed. When you write raw bytes, Lance chooses a storage layout based on the payload size. A descriptor’s kind tells you which one was used: Managed storage must be enabled separately. See Managed blobs for details. kind=5 identifies a Managed object using an explicitly registered base. Lance uses the default thresholds unless you customize them. Explicitly configured thresholds are stored in the table schema and preserved on append.

Setup

Import the SDK and open a local database. The examples in this section use db.

Define a blob schema

Declare a blob column with lancedb.blob() in Python and TypeScript, or blob() in Rust:

Ingest blobs

Pass raw bytes for each blob value. In Python, a blob column accepts bytes. In TypeScript, it accepts a Buffer. The Python example creates two JPEGs with EXIF metadata, which we’ll read using a blob handle below.

Read bytes in query results

Python and TypeScript can return blob bytes in query results without collecting _rowid. Rust uses fetch_blobs with row IDs instead. to_list(blob_mode="bytes") returns the image bytes from the photos table above:
In the Python SDK, Table.to_pandas(), Table.to_arrow(), and the query builder methods to_pandas(), to_arrow(), to_list(), and to_batches() accept a blob_mode argument. Sync and async query builders support the same modes: For supported local v2 blob projections, to_pandas(blob_mode="bytes") and to_pandas(blob_mode="descriptions") use native Arrow conversion without pylance. Pandas conversions that need the Lance scanner, including plain-scan "lazy" handles and legacy blobs, still require pylance. to_arrow(), to_list(), to_batches(), and the fetch methods do not require pylance. blob_mode="bytes" requires a LanceDB Embedded table snapshot, so Enterprise and in-memory tables raise NotImplementedError. On an Enterprise table, Table.to_pandas() and Table.to_arrow() aren’t supported, and a query’s to_pandas() also raises in the default "lazy" mode because it would open BlobFile handles. Query with blob_mode="descriptions", then read bytes with fetch_blobs or fetch_blob_files. blob_mode="bytes" materializes top-level and struct-nested blob columns. It raises ValueError when the selected columns include blobs inside a list. Select those columns with blob_mode="descriptions", or leave them out of the projection. Extra keyword arguments on to_pandas(), such as split_blocks and self_destruct, are forwarded to PyArrow. to_batches(blob_mode="bytes") fetches blob v2 payloads one batch at a time and returns them as Arrow large_binary values. Iterate over the reader and call batch.to_pandas() to convert each batch to a DataFrame without collecting all payloads at once. For sync queries, call query.to_batches(batch_size=64, blob_mode="bytes") and iterate with for. For async queries, call await query.to_batches(max_batch_length=64, blob_mode="bytes") and iterate with async for. Sync full-text and hybrid queries do not support to_batches(). The default "lazy" mode returns descriptors and keeps automatic _rowid columns visible for manual fetches. "descriptions" returns the five-field descriptors. Both "bytes" and "descriptions" hide automatic row IDs; explicitly requested row IDs remain visible. Bytes mode has the same restrictions as to_arrow(blob_mode="bytes"), including no legacy blobs, blobs inside lists, Enterprise tables, or namespace QueryTable pushdown. If concurrent writers can overwrite the table, use a separate reader checked out to a fixed version before querying. Otherwise, an overwrite can invalidate the row IDs used to fetch bytes and raise ValueError. The lazy-handle calls below require pylance. The query example expects tbl_async, query_vector, and a video column, which the photos table does not define.
Sync and async query builders accept the same blob_mode values:
In TypeScript, toArray() and toArrow() accept a blobMode option: With "bytes", the rows and the bytes come from the same table version. If the table changes while the query runs, the query runs again, up to three times:

Read blobs on demand

Query the rows you want and pass their _rowid values to a fetch method. fetch_blob_files opens a seekable handle and loads bytes only when you read. The Python example opens the JPEG from the ingest step and reads its EXIF Make tag with Pillow. TypeScript and Rust read the first bytes from the handle.
The other fetch methods load bytes directly:
  • fetch_blobs returns the full bytes for each row ID in order. Null blobs produce null entries, and empty blobs produce empty byte strings.
  • fetch_blob_files loads bytes when you call read, read_range, or read_ranges. Range reads don’t move the file cursor. Null blobs produce null handles.
  • fetch_blob_ranges takes (row_id, offset, length) requests and returns one slice per request, in order. A request list can repeat a row to read more than one slice of that blob.
Python and Rust read slices across rows with fetch_blob_ranges. On a handle, read_ranges reads more than one slice of one blob. Python takes (offset, length) pairs, TypeScript takes { start, end } ranges with an exclusive end, and Rust takes start..end ranges:

Notes and limits

Blob fetches fail if any requested row ID is missing or deleted. With stable row IDs enabled, IDs remain valid across compaction. A blob field can sit at the top level of the schema or inside a struct or list. Struct-nested blob columns use dotted paths such as clip.video in the fetch APIs. Blobs inside lists can be written and queried as descriptors. A table row ID does not address one list element, so blob_columns(), fetch_blobs, fetch_blob_files, and blob_mode="bytes" can’t return those bytes. See the Lance blob guide for the storage format.

Managed blobs (Blob v2)

Set the table schema metadata lance.blob.managed to true on a table with lancedb.blob() columns to store small values inline and larger values in managed objects under the table’s _blobs/ directory. Medium values can share a packed object, and large values use a dedicated object. The table owns these objects. You do not upload them separately, keep an object catalog, or delete their files yourself. Compaction preserves each descriptor and its payload object, so merging table fragments does not rewrite the blob payloads.
Use an SDK and Enterprise deployment that support Blob v2 Managed storage. Upgrade every reader, writer, and maintenance worker before activating Managed blobs on a table, and reopen existing table handles after the upgrade. Older clients cannot safely maintain a table containing Managed blobs.

Write and read blobs

This example uses a local connection. The same schema, add, query, and fetch_blobs calls work with an Enterprise database connection:
fetch_blobs materializes the selected payloads in memory, so fetch bounded batches when blobs are large. Row IDs belong to their table and snapshot. Keep descriptors opaque in application code, because their representation can differ between inline values and Managed objects.

Derived blobs and maintenance

Enterprise Functions can consume blob columns and return new blobs. Their output payloads are managed by the destination table. LanceDB Embedded tables can also derive blob columns with SQL computed columns. Deleting a row or replacing its blob does not immediately remove the old payload: retained table versions may still reference it. Garbage Collection reclaims objects after those references expire. A packed object remains while any of its contained blobs is still referenced.

Legacy large-binary blobs

Use lancedb.blob() for new tables. The sections below cover existing tables that use the V1 large-binary Blob API (pa.large_binary() in Python, LargeBinary in TypeScript, and DataType::LargeBinary in Rust with {"lance-encoding:blob": "true"} field metadata). Changing a field’s metadata alone does not migrate an existing V1 column to Blob v2.

1. Define a blob schema

V1 blob columns use a large_binary field marked with {"lance-encoding:blob": "true"} metadata:

2. Ingest large blobs

Write raw bytes to a V1 blob column:

3. Convert blob tables to pandas

On LanceDB Embedded tables with V1 blob columns, to_pandas() in Python accepts blob_mode ("lazy", "bytes", or "descriptions"). Enterprise tables raise NotImplementedError, and "bytes" is not supported on in-memory tables. Extra keyword arguments such as split_blocks or self_destruct are forwarded to the PyArrow / Lance pandas conversion:
On query builders with V1 blob columns, plain scans support "lazy", "bytes", and "descriptions". Vector, FTS, hybrid, and ordered queries cannot materialize V1 blob columns through to_pandas(), so omit those columns from the projection:

Store binary data

Use a binary column when the query should return the raw bytes. Declare it as pa.binary() in Python, Binary in TypeScript, and DataType::Binary in Rust.

1. Setup and imports

Import LanceDB and Arrow in your SDK.

2. Prepare data

Encode each image or file into raw bytes before writing.

3. Define the schema

Declare the schema explicitly so Arrow and LanceDB store the field as binary instead of inferring a string or integer list.

4. Ingest data

Create the table from the rows and schema.

Retrieve binary data

When you search the table, select the binary column alongside your metadata columns.

Convert bytes back to objects

Decode the returned bytes into an image or media buffer in your application.

Other modalities

For PDFs, audio recordings, and other files, keep searchable fields in separate columns so you can filter rows before fetching blob bytes.

bfloat16

Sixteen-bit floats in the bfloat16 layout, stored as fixed_size_binary(2). Useful as a vector element type when halving the memory matters more than the precision does.

Schema evolution

Schema evolution enables non-breaking modifications to a database table’s structure (such as adding columns, altering data types, or dropping fields) to adapt to evolving data requirements without service interruptions. LanceDB supports ACID-compliant schema evolution through granular operations (add/alter/drop columns), allowing you to:
  • Iterate Safely: Modify schemas in production with versioned datasets and backward compatibility
  • Scale Seamlessly: Handle ML model iterations, regulatory changes, or feature additions
  • Optimize Continuously: Remove unused fields or enforce new constraints without downtime
In SQL, schema changes are ALTER TABLE.

Schema evolution operations

LanceDB supports four primary schema evolution operations:
  1. Adding new columns: Extend your table with additional attributes
  2. Altering existing columns: Change column names, data types, or nullability
  3. Updating field metadata: Attach or change per-column Arrow metadata
  4. Dropping columns: Remove unnecessary columns from your schema
Schema evolution operations are applied immediately but do not typically require rewriting all data. However, data type changes may involve more substantial operations.
Each schema evolution operation commits a new table version and returns status metadata such as the committed version. Run these operations from a mutable table handle; if you checked out an older version for reads, call checkout_latest / checkoutLatest before modifying the schema.

Add new columns

You can add new columns to a table with the add_columns method in Python, addColumns in TypeScript/JavaScript, or add_columns in Rust. New columns are populated based on SQL expressions you provide. On local tables, the Python SDK also accepts precomputed values from DataFrames and Arrow readers; see Create a computed column for the supported inputs and row-order requirements.

Set up the example table

First, let’s create a sample table with product data to demonstrate schema evolution:
Columns derived from existing data (SQL expressions over other columns) are covered in Create a computed column, because deriving values is a feature-engineering job rather than a schema one.

Add columns with default values

Add boolean columns with default values for status tracking:

Add nullable columns

Add timestamp columns that can contain NULL values:
When adding columns that should contain NULL values, be sure to cast the NULL to the appropriate type, e.g., cast(NULL as timestamp).

Alter existing columns

You can alter columns using the alter_columns method in Python, alterColumns in TypeScript/JavaScript, or alter_columns in Rust. This allows you to:
  • Rename a column
  • Change a column’s data type
  • Modify nullability (whether a column can contain NULL values)

Set up the example table

Create a table with a custom schema to demonstrate column alterations:

Rename columns

Change column names to better reflect their purpose:

Change data types

Convert column data types for better performance or compatibility:

Make columns nullable

You can alter columns to contain NULL values:
Changing a column to nullable affects future writes and merges too: missing values are accepted only when the target column is nullable.

Multiple changes at once

Apply several alterations in a single operation:

Expression-based type changes

For transformations that are not simple casts (for example, converting "$100" to an integer), use a SQL-expression column add, then drop and rename:

Alter embedding types and dimensions

It’s quite common to need to change an embedding column’s schema, in case a new model becomes available with a different embedding dimension.
  • In Python, the example shows an in-place type update when the cast is compatible.
  • In TypeScript and Rust, the example shows a dimension change (384 -> 1024), which cannot be cast in-place.
For dimension changes, use this 3-step pattern: add a new column with the target type, drop the old column, then rename the new column to the original name.
FixedSizeList Dimension Changes in TypeScript and RustalterColumns / alter_columns can cast between compatible types, but changing FixedSizeList dimensions (for example 384 -> 1024) is not a compatible cast. For such cases, use addColumns / add_columns (with arrow_cast), then dropColumns / drop_columns, then rename the replacement column.
Changing data types requires rewriting the column data and may be resource-intensive for large tables. Renaming columns or changing nullability is more efficient as it only updates metadata.

Update field metadata

Each column in a LanceDB table can carry a small key/value map of Arrow field metadata, which is useful for annotating columns with units, provenance, PII flags, embedding model versions, or any other schema-level context your application needs. Use update_field_metadata in Python, updateFieldMetadata in TypeScript/JavaScript, or update_field_metadata in Rust to add, change, or remove these key/value pairs without rewriting the column data. Each call commits a new table version and returns the new version. Each update targets one field by dot-path: top-level columns are addressed by name (for example "embedding"), and nested fields by their full path (for example "address.zip"). By default, the keys you pass are merged into the field’s existing metadata: keys you do not mention are preserved, and passing None (Python) or null (TypeScript) deletes a key. Set replace: true to swap the field’s entire metadata map instead of merging.
To overwrite a field’s metadata entirely instead of merging, set replace to true:
You can pass multiple updates in a single call to change metadata on several fields at once. Each call commits a single new table version.

Canonical metadata keys

You can use any string as a metadata key. By convention, LanceDB treats keys prefixed with lancedb: as canonical. LanceDB Enterprise displays these keys in the UI, and agents can rely on finding this information in a consistent place. Use them when they fit your use case:
These keys are conventions, not enforced constraints. LanceDB does not validate their values, but following the conventions keeps your tables consistent with Enterprise tooling.

Drop columns

You can remove columns using the drop_columns method in Python, dropColumns in TypeScript/JavaScript, or drop_columns in Rust.

Set Up the example table

Create a table with temporary columns that we’ll remove:

Drop single columns

Remove individual columns that are no longer needed:

Drop multiple columns

Remove several columns at once for efficiency:
Dropping a column changes the latest version only. Until cleanup prunes the versions from before the drop, restoring one of them brings the column and its values back, along with the rest of the table as it was then. Once those versions are pruned, the drop cannot be undone, so check before dropping a column you may need.

Versioning

This page shows the core table-versioning APIs used in the code snippets for Python, TypeScript, and Rust. Each operation below maps directly to methods shown in the examples.

Basic Versioning Example

Let’s create a table with sample data to demonstrate LanceDB’s versioning capabilities:

Set Up the Table

First, let’s create a table with some sample data:

Check Initial Version

After creating the table, let’s check the initial version information:

Modify Data

When you modify data through operations like update or delete, LanceDB automatically creates new versions.

Update Existing Data

Let’s update some existing records to see versioning in action:

Add New Data

Now let’s add more records to the table:

Check Version Changes

Let’s see how the versions have changed after our modifications:

Rollback to Previous Versions

LanceDB supports fast rollbacks to any previous version without data duplication.

View All Versions

First, let’s see all the versions we’ve created:

Restore a Version Snapshot

Now let’s restore a captured version snapshot:

Tag-Based Versioning

Numeric table versions like v3 or v17 are precise but hard to remember. Tags let you attach human-readable labels (e.g., "prod", "baseline", "q3-evaluation") to specific versions and check those out by name. They are conceptually similar to git tags, and unlike numeric versions, tagged versions are never pruned. A tagged version that is older than the retention window and is not the latest stops cleanup instead (see the cleanup note at the bottom of this page). The tags API supports the standard CRUD operations (create, list, update, delete), plus checking out by tag name.
Deleting a tag only removes the label, not the version it points to. After deletion, the underlying table version becomes eligible for cleanup again.

Delete Data From the Table

Let’s demonstrate how deletions also create new versions:

Go Back to Latest Version

First, let’s return to the latest version:

Delete Data

Now let’s delete some data to see how it affects versioning:

Version History and Operations

On a fresh table, the snippets in this guide produce this version sequence:
  1. v1: create table (create_table / createTable / create_table)
  2. v2: update rows (update)
  3. v3: add rows (add)
  4. v4: restore snapshot (restore) from version_after_mod/versionAfterMod
  5. v5: delete rows (delete)
Read-only and checkout operations shown here (list_versions/listVersions, version, checkout, checkout_latest/checkoutLatest) do not create new versions. The version metadata fields can differ by backend. Direct table-backed version listing exposes a timestamp, while namespace-backed listing may expose fields such as manifest_path, manifest_size, e_tag, and timestamp_millis. In deployments that use managed versioning, prefer the table version APIs exposed by LanceDB Enterprise or the namespace service instead of mixing in lower-level Lance file operations.
System OperationsSystem operations like optimize(), index updates, and table compaction also increment table version numbers. In LanceDB Embedded, optimize() can prune older versions based on its retention setting (cleanup_older_than, 7 days by default), which is when old-version files are removed and disk space is reclaimed. A deployment prunes them in the background when its configuration enables cleanup; optimize() is not available there.Tagged versions are never pruned. A version with a tag pointing at it is retained regardless of age. When a tagged version is older than the retention window and is not the latest, cleanup refuses to run instead of pruning around it: optimize() raises an error naming the tag and prunes nothing. A tag on the latest version does not cause this, but optimize() compacts before it prunes, and compacting commits a new version whenever it merges files, so a tag on what was the latest version can become an older one by the time cleanup runs; the error then comes after compacting, and the index update does not run. To let cleanup proceed, move the tag to a version inside the retention window, delete it, or widen the window.

Privileges

A table’s OWNERSHIP, SELECT, INSERT, UPDATE and DELETE are granted through the REST ACL API or SQL GRANT/REVOKE:
See Privileges for the full statement syntax and every target it reaches.