Skip to main content
UDFs are the core building block for feature engineering in Geneva. A UDF wraps a Python function and applies it to every row in a table, producing exactly one output value per input row (1:1). Use UDFs to compute embeddings, enrich data with external APIs, transform formats, or derive new features from existing columns.

Defining a UDF

Converting your Python code to a Geneva UDF is simple. There are three kinds of UDFs that you can provide — scalar UDFs, batched UDFs and stateful UDFs. In all cases, Geneva uses Python type hints from your functions to infer the input and output arrow data types that LanceDB uses.

Scalar UDFs

The simplest form is a scalar UDF, which processes one row at a time:
This UDF will take the value of x and value of y from each row and return the product. The @udf wrapper is all that is needed.

Batched UDFs

For better performance, you can also define batch UDFs that process multiple rows at once. You can use pyarrow.Arrays:
Or take entire rows using pyarrow.RecordBatch:
Note: Batch UDFS require you to specify data_type in the @udf decorator for batched UDFs which defines pyarrow.DataType of the returned pyarrow.Array.

Struct outputs

A UDF can return multiple related values as a single struct column by setting data_type to a pa.struct(...) and returning a tuple (matched by field order) or a dict keyed by field name.
Downstream UDFs can then read individual fields via dot notation in input_columns (see below).

Struct fields and list inputs

You can pass nested struct fields directly into a UDF by specifying input_columns with dot notation. For list-typed inputs, Geneva can pass a NumPy array when the argument is annotated as np.ndarray (use np.ndarray | None for nullable lists).

Stateful UDFs

You can also define a stateful UDF that retains its state across calls. This can be used to share code and parameterize your UDFs. In the example below, the model being used is a parameter that can be specified at UDF registration time. It can also be used to parameterize input column names of pa.RecordBatch batch UDFS. This also can be used to optimize expensive initialization that may require heavy resources on the distributed workers. For example, this can be used to load a model to the GPU once for all records sent to a worker instead of once per record or per batch of records. A stateful UDF is a Callable class, with __call__() method. The call method can be a scalar function or a batched function.
For common providers like OpenAI and Gemini, Geneva ships built-in UDFs that handle API keys, retries, and batching for you — no custom class needed.
Note: The state is will be independently managed on each distributed Worker.

UDF options

The udf can have extra annotations that specify resource requirements and operational characteristics. These are just add parameters to the udf(...).

Resource requirements for UDFs

Some workers may require specific resources such as gpus, cpus and certain amounts of RAM. You can provide these requirements by adding num_cpus, num_gpus, and memory parameters to the UDF.

Operational parameters for UDFs

checkpoint_size

checkpoint_size controls how many rows are processed before checkpointing, and therefore reporting and saving progress. UDFs can be quite varied: some can be simple operations where thousands of calls can be completed per second, while others may be slow and require 30s per row. So a simple default like “every 1000 rows” might write once a second or once every 8 hours! Geneva will handle this internally, using an experimental feature that will adapt checkpoint sizing as a UDF progresses. However, if you want to see writes more or less frequently, you can set this manually. There are three parameters:
  • checkpoint_size: the seed for the initial checkpoint size
  • min_checkpoint_size: the minimum value that Geneva will use while adapting checkpoint size
  • max_checkpoint_size: the maximum value that Geneva will use while adapting checkpoint size
Therefore, to force a checkpoint size (and effectively disable adaptive batch sizing), set all three of these parameters to the same value.

Error handling

Depending on the UDF, you may want Geneva to ignore rows that hit failures, retry, or fail the entire job. For simple cases, Geneva provides a simple parameter, on_error, with the following options: If those are not specific enough, Geneva also provides many more error handling options.

Registering Features with UDFs

Registering a feature is done by providing the Table.add_columns() function a new column name and the Geneva UDF.
Here’s how to register a simple UDF:

Registering Multi-Output UDFs

Use a multi-output UDF when one expensive read or decode can produce several features. For example, if a table stores image bytes, a single UDF can open the image once and return height, width, and an embedding column together. This avoids separate UDFs that would each read or decode the same image. Define the output shape with typing.NamedTuple and annotate the UDF return type as geneva.Columns[YourNamedTuple]. Passing that UDF directly to Table.add_columns() expands the result into multiple sibling columns using the NamedTuple field names. If those names need a namespace or would conflict with existing columns, wrap the UDF with geneva.UnpackedUDF(udf, prefix="...") before calling add_columns(). The prefix is added to each materialized column name while keeping the outputs in one logical feature group. Manage multi-output sibling columns as a group. Backfill, drop, or alter the full group together instead of changing only one sibling column.
Batched UDFs require return type in their udf annotations
or
Similarly, a stateful UDF is registered by providing an instance of the Callable object. The call method may be a per-record function or a batch function.

Changing data in computed columns

Let’s say you backfilled data with your UDF then you noticed that your data has some issues. Here are a few scenarios:
  1. All the values are incorrect due to a bug in the UDF.
  2. Most values are correct but some values are incorrect due to a failure in UDF execution.
  3. Values calculated correctly and you want to perform a second pass to fixup some of the values.
In scenario 1, you’ll most likely want to replace the UDF with a new version and recalculate all the values. You should perform a alter_table and then backfill. In scenario 2, you’ll most likely want to re-execute backfill to fill in the values. If the error is in your code (certain cases not handled), you can modify the UDF, and perform an alter_table, and then backfill with some filters. In scenario 3, you have a few options. A) You could alter your UDF and include the fixup operations in the UDF. You’d alter_table and then backfill recalculating all the values. B) You could have a chain of computed columns — create a new column, calculate the “fixed” up values and have your application use the new column or a combination of the original column. This is similar to A but does not recalculate A and can incur more storage. C) You could update the values in the column with the fixed up values. This may be expedient but also sacrifices reproducibility. The next section shows you how to change your column definition by altering the UDF.

Altering UDFs

You now want to revise the code. To make the change, you’d update the UDF used to compute the column using the alter_columns API and the updated function. The example below replaces the definition of column area to use the area_udf_v2 function.
After making this change, the existing data already in the table does not change. However, when you perform your next basic backfill operation, all values would be recalculated and updated. If you only wanted some rows updated, you could perform a filtered backfill, targeting the specific rows that need the new upates. For example, this filter would only update the rows where area was currently null.

Auto-backfill

For columns whose values should always stay in sync with their source data, set auto_backfill=True on the UDF. On LanceDB Enterprise (db:// connections), the column is then recomputed for you automatically — you don’t need to call backfill() yourself.

How it works

The auto_backfill flag is recorded in the column’s metadata when the column is added or altered. LanceDB Enterprise’s managed agent watches for columns that need recomputation and dispatches a distributed backfill job automatically — there is no manual trigger and no status polling. A column is recomputed when, for example:
  • New rows are added to the table (tbl.add(...)), leaving the column null for those rows.
  • The UDF version changes — you bump version= and alter_columns() to the new function.
Auto-backfill is an enterprise feature. On direct object-storage or local-filesystem connections there is no managed agent, so auto_backfill=True has no effect and you must run backfill() explicitly.
Reference: