Access OSS Tables through the Python SDK

Updated at:

pylance is the official Python SDK for Lance. It supports catalog operations, data read and write, row-level DML, time travel, schema evolution, scalar/vector/full-text indexing, vector search, and small-file compaction on Lance tables in OSS Tables. It is suitable for data science, feature engineering, and AI data pipelines.

Prerequisites

  • A Table Bucket has been created in OSS Tables, and an Alibaba Cloud AccessKey (AccessKey ID and AccessKey Secret) has been obtained. If not, see Create table bucket.

  • Access credentials (AccessKey or STS temporary credentials) have been obtained, and the credentials have read and write permissions on the target Table Bucket.

  • Python 3.12 or later.

  • The runtime can access the OSS Tables control plane and data plane endpoints.

Step 1: Prepare the environment

Install the following combination of versions:

pip install \
  osstables-lance-connector==1.0.0rc2 \
  pylance==12.0.0 \
  lance-namespace==0.11.1 \
  pyarrow==25.0.1 \
  numpy==2.5.3

osstables-lance-connector is a pure Python package. After installation, it provides the osstables Lance Namespace implementation. To keep the examples reproducible, do not upgrade these dependencies individually in the same environment; upgrade them together when needed.

Step 2: Configure the connection

Importing the connector registers the osstables alias. Then use lance_namespace.connect to establish the catalog connection:

import lance_namespace
import osstables_lance_connector  # Importing registers the "osstables" implementation

ns = lance_namespace.connect("osstables", {
    # Control plane (catalog) configuration
    "osstables.uri": "https://<tablebucket>-<uid>.cn-beijing-internal.oss-tables.aliyuncs.com/lance",
    "osstables.region": "cn-beijing",
    "osstables.access_key_id": "<Control-plane AccessKey ID>",
    "osstables.secret_access_key": "<Control-plane AccessKey Secret>",
})

Data plane (OSS) credentials are passed in through storage_options on each read or write:

STORAGE_OPTIONS = {
    "access_key_id": "<Data-plane AccessKey ID>",
    "access_key_secret": "<Data-plane AccessKey Secret>",
    "endpoint": "https://oss-cn-beijing-internal.aliyuncs.com",
    "region": "cn-beijing",
}

Data plane storage_options parameters:

Parameter

Required

Description

access_key_id

Yes

OSS AccessKey ID

access_key_secret

Yes

OSS AccessKey Secret

security_token

No

Security Token used with STS temporary credentials

endpoint

Yes

OSS data plane endpoint

region

Yes

OSS region ID, must match the Table Bucket region

Important

Control plane credentials (osstables.*) and data plane credentials (storage_options) are independent of each other and must both be configured when reading or writing data. When using STS temporary credentials, pay attention to validity periods and rotation.

Step 3: Operate on data

The following examples uniformly use the table identifier ["my_db", "my_table"] (that is, namespace=my_db, table=my_table).

Manage namespaces and the catalog

Catalog request/response models are all imported from lance_namespace:

from lance_namespace import (
    CreateNamespaceRequest, ListNamespacesRequest, DescribeNamespaceRequest,
    NamespaceExistsRequest, DropNamespaceRequest,
    DeclareTableRequest, CreateTableRequest, DescribeTableRequest,
    TableExistsRequest, ListTablesRequest, RenameTableRequest,
    DeregisterTableRequest, DropTableRequest,
)

DB = "my_db"

# Namespace (database) operations
ns.create_namespace(CreateNamespaceRequest(id=[DB]))
ns.namespace_exists(NamespaceExistsRequest(id=[DB]))
ns.describe_namespace(DescribeNamespaceRequest(id=[DB]))

ns.list_namespaces(ListNamespacesRequest(id=[]))            # id=[] means root

# List tables (returns bare table names, e.g. ["t1"])
ns.list_tables(ListTablesRequest(id=[DB]))

ns.list_all_tables(ListTablesRequest(id=[]))                # Across all namespaces

describe_table supports three optional parameters to obtain richer metadata:

DescribeTableRequest(id=[DB, "t1"], check_declared=True)         # Populates is_only_declared
DescribeTableRequest(id=[DB, "t1"], with_table_uri=True)         # Additionally returns table_uri
DescribeTableRequest(id=[DB, "t1"], load_detailed_metadata=True) # Returns version / schema / stats
Note

The default response does not include the schema; use load_detailed_metadata=True to read the schema.

Create a table and write data

Use lance.write_dataset to create a table and write data, passing in namespace_client and table_id:

import lance
import pyarrow as pa

TABLE_ID = ["my_db", "my_table"]

# Create table + write
lance.write_dataset(
    pa.table({"id": [1, 2, 3], "name": ["a", "b", "c"]}),
    namespace_client=ns, table_id=TABLE_ID,
    mode="create", storage_options=STORAGE_OPTIONS,
)

# Append
lance.write_dataset(
    pa.table({"id": [4, 5], "name": ["d", "e"]}),
    namespace_client=ns, table_id=TABLE_ID,
    mode="append", storage_options=STORAGE_OPTIONS,
)

mode="create" creates a new table; mode="append" / "overwrite" require the table to already exist.

You can also create a table directly through the catalog with an Arrow IPC byte stream (the server writes the data, so no data plane credentials are required):

import io
tbl = pa.table({"id": [1, 2, 3]})
sink = io.BytesIO()
with pa.ipc.new_stream(sink, tbl.schema) as w:
    w.write_table(tbl)
ns.create_table(CreateTableRequest(id=["my_db", "t2"]), sink.getvalue())

Read data and time travel

Open a table to read the current version, or specify a historical version number to travel back to the data state before a given change.

# Read the current version
ds = lance.dataset(namespace_client=ns, table_id=TABLE_ID, storage_options=STORAGE_OPTIONS)
print(ds.count_rows(), ds.version)
print(ds.to_table().to_pydict())

# Time travel: read a historical version
old = lance.dataset(namespace_client=ns, table_id=TABLE_ID, version=1,
                    storage_options=STORAGE_OPTIONS)
print(old.count_rows())

Row-level DML (update / delete / merge)

Update or delete specific rows by predicate, or perform an upsert on a join key: matched rows are updated and unmatched rows are inserted.

ds = lance.dataset(namespace_client=ns, table_id=TABLE_ID, storage_options=STORAGE_OPTIONS)

# Update: updates is {column_name: SQL expression}; string literals need inner single quotes (e.g. "'updated'").
ds.update({"name": "'updated'"}, where="id = 1")           # Set name to the string "updated"
ds.delete("id = 5")                                        # Delete (predicate)

# merge (upsert)
ds.merge_insert("id") \
  .when_matched_update_all() \
  .when_not_matched_insert_all() \
  .execute(pa.table({"id": [3, 6], "name": ["c-new", "f"]}))

Scalar index, vector index, and search

Build a B-tree index on a regular column to speed up point lookups, an IVF_PQ index on a vector column for approximate nearest neighbor search, and an inverted index on a text column for full-text search.

import numpy as np

# Write a table with a vector column (IVF_PQ training has data-volume requirements; the example writes 70,000 rows).
dim = 128
n = 70000
rng = np.random.default_rng(42)
data = pa.table({
    "id": pa.array(range(n), pa.int64()),
    "text": pa.array([f"doc number {i} category {i % 7}" for i in range(n)], pa.string()),
    "vector": pa.FixedSizeListArray.from_arrays(
        pa.array(rng.random((n, dim), dtype=np.float32).reshape(-1), pa.float32()), dim),
})
lance.write_dataset(data, namespace_client=ns, table_id=["my_db", "vecs"],
                    mode="create", storage_options=STORAGE_OPTIONS)
vds = lance.dataset(namespace_client=ns, table_id=["my_db", "vecs"],
                    storage_options=STORAGE_OPTIONS)

# Scalar index (BTREE)
vds.create_scalar_index("id", index_type="BTREE")
vds.to_table(filter="id = 12345")            # Point lookup

# Vector index (IVF_PQ) + vector search
vds.create_index("vector", index_type="IVF_PQ", num_partitions=16, num_sub_vectors=16)
qv = rng.random((dim,), dtype=np.float32)
res = vds.to_table(nearest={"column": "vector", "q": qv, "k": 5})
print(res["id"].to_pylist(), [round(d, 4) for d in res["_distance"].to_pylist()])

# Full-text search (INVERTED inverted index)
vds.create_scalar_index("text", index_type="INVERTED")
hits = vds.to_table(full_text_query="category", limit=5)   # Result includes the _score relevance column
print(hits.column_names)                                    # ['id', 'text', 'vector', '_score']
# You can also specify the search columns: full_text_query={"columns": ["text"], "query": "category"}

Schema evolution

Add columns, drop columns, rename columns, or change column types without rewriting the entire table; this only produces new version metadata.

# Add a column (value is a SQL expression; you can also pass a BatchUDF / Arrow schema)
vds.add_columns({"score": "CAST(id AS DOUBLE)"})
# Drop a column
vds.drop_columns(["score"])
# Rename (alter_columns takes path + target attribute)
vds.alter_columns({"path": "text", "name": "content"})
# Change column type (int32 -> int64)
vds.add_columns({"n32": "CAST(id AS INT)"})                   # n32 is int32
vds.alter_columns({"path": "n32", "data_type": pa.int64()})   # int32 -> int64

Small-file compaction and version cleanup

Compact small files produced by writes to improve read performance, incrementally update indexes to cover new data, and clean up historical version files that are no longer referenced to release storage.

# Compact small files
vds.optimize.compact_files()
# Incrementally train / merge indexes (newly written data is brought into existing indexes)
vds.optimize.optimize_indices()

# Clean up historical version files no longer referenced
vds.cleanup_old_versions()

Drop tables and namespaces

Drop a table (including its data; irreversible) or only deregister the table metadata while keeping the underlying data. A namespace can be dropped only after all tables beneath it have been cleared.

# Drop tables (data is irreversible)
for table_name in ["vecs", "t2"]:
    ns.drop_table(DropTableRequest(id=["my_db", table_name]))

# For my_table, choose one of the two:
# Option 1: Only deregister catalog metadata; underlying data is kept
ns.deregister_table(DeregisterTableRequest(id=["my_db", "my_table"]))
# Option 2: If you are sure the data is no longer needed, use instead:
# ns.drop_table(DropTableRequest(id=["my_db", "my_table"]))

# After the catalog no longer contains the table, drop the namespace
ns.drop_namespace(DropNamespaceRequest(id=["my_db"]))
Important

drop_table deletes data and is irreversible; proceed with caution. To only remove a table definition from the catalog while keeping the data, use deregister_table.

Error handling

Catch catalog call failures by the standard exception types under lance_namespace.errors and perform idempotent retries or user-friendly prompts.

from lance_namespace import NamespaceAlreadyExistsError

try:
    ns.create_namespace(CreateNamespaceRequest(id=["my_db"]))
except NamespaceAlreadyExistsError:
    pass

Notes

  • Control plane credentials and data plane credentials must both be configured; neither can be omitted. When using STS temporary credentials, pay attention to validity periods and rotation.

  • Table names and database names must use ASCII characters.

  • The pylance package is imported as import lance, not import pylance.

  • For lance.write_dataset mode: create calls declare_table (create a new table); append / overwrite call describe_table (the table must already exist).

  • Vector columns are of type FixedSizeList<Float32, N>. Building an IVF_PQ index has data-volume requirements; too little data causes training to fail.