Resource
Declare a resource
A resource is an (optionally async) function that yields data. A resource can yield Python dicts/lists, Pydantic models, Arrow tables, Pandas DataFrames, or Polars DataFrames/LazyFrames. To create a resource, we add the @dlt.resource decorator to that function.
Commonly used arguments:
name: The name of the table generated by this resource. Defaults to the decorated function name.write_disposition: How should the data be loaded at the destination? Currently supported:append,replace, andmerge. Defaults toappend.
Example:
@dlt.resource(name='table_name', write_disposition='replace')
def generate_rows():
for i in range(10):
yield {'id': i, 'example_string': 'abc'}
@dlt.source
def source_name():
return generate_rows
# To get the data of a resource, we could do:
for row in generate_rows():
print(row)
for row in source_name().resources['table_name']:
print(row)
Typically, resources are declared and grouped with related resources within a source function.
Define schema
dlt will infer the schema for tables associated with resources from the resource's data.
You can modify the generation process by using the table and column hints. The resource decorator accepts the following arguments:
table_name: the name of the table, if different from the resource name.primary_keyandmerge_key: define the name of the columns (compound keys are allowed) that will receive those hints. Used in incremental loading and merge loading.columns: lets you define one or more columns, including the data types, nullability, and other hints. The column definition is aTypedDict:TTableSchemaColumns. In the example below, we telldltthat the columntags(containing a list of tags) in theusertable should have typejson, which means that it will be loaded as JSON/struct and not as a separate nested table.
When using primary_key, merge_key, or columns, be aware of the precedence and replacement rules of compound hints.
@dlt.resource(name="user", columns={"tags": {"data_type": "json"}})
def get_users():
...
# the `table_schema` method gets the table schema generated by a resource
print(get_users().compute_table_schema())
You can pass dynamic hints which are functions that take the data item as input and return a hint value. This lets you create table and column schemas depending on the data. See an example below.
Put a contract on tables, columns, and data
Use the schema_contract argument to tell dlt how to deal with new tables, data types, and bad data types. For example, if you set it to freeze, dlt will not allow for any new tables, columns, or data types to be introduced to the schema - it will raise an exception. Learn more about available contract modes here.
Define schema of nested tables
dlt creates nested tables to store list of objects if present in your data.
You can define the schema of such tables with nested_hints argument to @dlt.resource:
import dlt
@dlt.resource(
nested_hints={
"purchases": dlt.mark.make_nested_hints(
columns=[{"name": "price", "data_type": "decimal"}],
schema_contract={"columns": "freeze"},
)
},
)
def customers():
"""Load customer data from a simple python list."""
yield [
{
"id": 1,
"name": "simon",
"city": "berlin",
"purchases": [{"id": 1, "name": "apple", "price": "1.50"}],
},
]
Here we convert the price field in list of purchases to decimal type and set the schema contract to lock the list
of columns in it. We use convenience function dlt.mark.make_nested_hints to generate nested hints dictionary. You are
free to use it directly.
Mind that purchases list will be stored as table with name customers__purchases. When declaring nested hints you just need
to specify nested field(s) name(s). In case of deeper nesting i.e. let's say each purchase has a list of coupons applied,
you can apply hints to coupons and define customers__purchases__coupons table schema:
import dlt
@dlt.resource(
nested_hints={
"purchases": {},
("purchases", "coupons"): {
"columns": {"registered_at": {"data_type": "timestamp"}}
}
},
)
def customers():
...
Here we use ("purchases", "coupons") to locate list at the depth of 2 and set the data type on registered_at column
to timestamp. We do that by directly using nested hints dict.
Note that we specified purchases with an empty list of hints. You are required to specify all parent hints, even if they
are empty. Currently we are not adding missing path elements automatically.
You can use nested_hints primarily to set column hints and schema contract, those work exactly as in case of root tables.
file_formathas no effect (not implemented yet)write_dispositionworks as expected but leads to unintended consequences (i.e. you can set nested table toreplace) while root table isappend.referenceswill create table references (annotations) as expected.primary_keyandmerge_key: setting those will convert nested table into a regular table, with a separate write disposition, file format etc. It allows you to create custom table relationships i.e. using natural primary and foreign keys present in the data.
REST API Source accepts nested_hints argument as well.
You can apply nested hints after the resource was created by using apply_hints.
Define a schema with Pydantic
You can alternatively use a Pydantic model to define the schema. For example:
from pydantic import BaseModel
from typing import Optional, Union
class Address(BaseModel):
street: str
city: str
postal_code: str
class User(BaseModel):
id: int
name: str
tags: list[str]
email: Optional[str]
address: Address
status: Union[int, str]
@dlt.resource(name="user", columns=User)
def get_users():
...
The data types of the table columns are inferred from the types of the Pydantic fields. These use the same type conversions as when the schema is automatically generated from the data.
Pydantic models integrate well with schema contracts as data validators.
Things to note:
- Fields with an
Optionaltype are marked asnullable. - Fields with a
Uniontype are converted to the first (notNone) type listed in the union. For example,status: Union[int, str]results in abigintcolumn. list,dict, and nested Pydantic model fields will use thejsontype, which means they'll be stored as a JSON object in the database instead of creating nested tables.
You can override this by configuring the Pydantic model:
from typing import ClassVar
from dlt.common.libs.pydantic import DltConfig
class UserWithNesting(User):
dlt_config: ClassVar[DltConfig] = {"skip_nested_types": True}
@dlt.resource(name="user", columns=UserWithNesting)
def get_users():
...
The following DltConfig options are available:
-
"skip_nested_types": Omits anydict/list/BaseModeltype fields from the schema, so dlt will fall back on the default behavior of creating nested tables for these fields. -
"return_validated_models": By default, when using a Pydantic model for validation, dlt converts the validated model instances to dictionaries before passing them downstream (e.g., to transformers). If you set this option toTrue, dlt will return the validated Pydantic model instances instead. This is useful when you want to work with Pydantic models in transformers without having to reconstruct them from dictionaries.
Example with return_validated_models:
from typing import ClassVar
from pydantic import BaseModel
from dlt.common.libs.pydantic import DltConfig
import dlt
class SimpleUser(BaseModel):
id: int
name: str
dlt_config: ClassVar[DltConfig] = {"return_validated_models": True}
@dlt.resource(columns=User)
def simple_users():
yield {"id": 1, "name": "Alice"}
yield SimpleUser(id=2, name="Bob")
@dlt.transformer(data_from=simple_users)
def process_users(user: SimpleUser):
# user is a Pydantic model instance, not a dict
print(f"Processing user: {user.name}")
yield user
# Transformers receive User model instances directly
pipeline = dlt.pipeline(destination="duckdb")
pipeline.run(process_users)
RootModel is supported for discriminated unions, where a single resource dispatches items of different types to separate tables. We do not support RootModel that validates simple types. You can add such a validator yourself, see data filtering section.
Dispatch data to many tables
You can load data to many tables from a single resource. The most common case is a stream of events
of different types, each with a different data schema. To deal with this, you can use the table_name
argument on dlt.resource. You could pass the table name as a function with the data item as an
argument and the table_name string as a return value.
For example, a resource that loads GitHub repository events wants to send issue, pull request,
and comment events to separate tables. The type of the event is in the "type" field.
from typing import Iterator
from dlt.common.typing import TDataItems
# send item to a table with name item["type"]
@dlt.resource(table_name=lambda event: event['type'])
def repo_events() -> Iterator[TDataItems]:
yield item
# the `table_schema` method gets the table schema generated by a resource and takes an optional
# data item to evaluate dynamic hints
print(repo_events().compute_table_schema({"type": "WatchEvent", "id": ...}))
In more advanced cases, you can dispatch data to different tables directly in the code of the resource function:
from typing import Iterator
from dlt.common.typing import TDataItems
@dlt.resource
def repo_events() -> Iterator[TDataItems]:
# mark the "item" to be sent to the table with the name item["type"]
yield dlt.mark.with_table_name(item, item["type"])
Created tables share the hints defined on the resource and evolve according to the received data. If you need separate
hints per table, use resource.apply_hints with the create_table_variant option. This is an advanced feature, useful for building
e.g. an incremental replication resource where a single resource can create many independent tables.
Parametrize a resource
You can add arguments to your resource functions like to any other. Below we parametrize our
generate_rows resource to generate the number of rows we request:
@dlt.resource(name='table_name', write_disposition='replace')
def generate_var_rows(nr):
for i in range(nr):
yield {'id': i, 'example_string': 'abc'}
for row in generate_var_rows(10):
print(row)
for row in generate_var_rows(20):
print(row)
You can mark some resource arguments as configuration and credentials values so dlt can pass them automatically to your functions.
Process resources with dlt.transformer
You can feed data from one resource into another. The most common case is when you have an API that returns a list of objects (i.e., users) in one endpoint and user details in another. You can deal with this by declaring a resource that obtains a list of users and another resource that receives items from the list and downloads the profiles.
@dlt.resource(write_disposition="replace")
def users(limit=None):
for u in _get_users(limit): # ty: ignore[unresolved-reference]
yield u
# Feed data from users as user_item below,
# all transformers must have at least one
# argument that will receive data from the parent resource
@dlt.transformer(data_from=users)
def users_details(user_item):
for detail in _get_details(user_item["user_id"]): # ty: ignore[unresolved-reference]
yield detail
# Just load the users_details.
# dlt figures out dependencies for you.
pipeline.run(users_details)
In the example above, users_details will receive data from the default instance of the users resource (with limit set to None). You can also use the pipe | operator to bind resources dynamically.
# You can be more explicit and use a pipe operator.
# With it, you can create dynamic pipelines where the dependencies
# are set at run time and resources are parametrized, i.e.,
# below we want to load only 100 users from the `users` endpoint.
pipeline.run(users(limit=100) | users_details)
Transformers are allowed not only to yield but also to return values and can decorate async functions and async generators. Below we decorate an async function and request details on two pokemons. HTTP calls are made in parallel via the httpx library.
import dlt
import httpx
@dlt.transformer
async def pokemon(id):
async with httpx.AsyncClient() as client:
r = await client.get(f"https://pokeapi.co/api/v2/pokemon/{id}")
return r.json()
# Get Bulbasaur and Ivysaur (you need dlt 0.4.6 for the pipe operator working with lists).
print(list([1,2] | pokemon()))
Declare a standalone resource
A standalone resource is defined on a function that is top-level in a module (not an inner function) that accepts config and secrets values. Here dlt.resource just wraps the decorated function, and the user must call the wrapper to get the actual resource. Below we declare a filesystem resource that must be called before use.
@dlt.resource
def fs_resource(bucket_url=dlt.config.value):
"""List and yield files in `bucket_url`."""
...
# `filesystem` must be called before it is extracted or used in any other way.
pipeline.run(fs_resource("s3://my-bucket/reports"), table_name="reports")
Resource may have a dynamic name that depends on the arguments passed to the decorated function. For example:
@dlt.resource(name=lambda args: args["stream_name"])
def kinesis(stream_name: str):
...
kinesis_stream = kinesis("telemetry_stream")
kinesis_stream resource has a name telemetry_stream.
Declare parallel and async resources
You can extract multiple resources in parallel threads or with async IO.
To enable this for a sync resource, you can set the parallelized flag to True in the resource decorator:
@dlt.resource(parallelized=True)
def get_users():
for u in _get_users(): # ty: ignore[unresolved-reference]
yield u
@dlt.resource(parallelized=True)
def get_orders():
for o in _get_orders(): # ty: ignore[unresolved-reference]
yield o
# users and orders will be iterated in parallel in two separate threads
pipeline.run([get_users(), get_orders()])
Async generators are automatically extracted concurrently with other resources:
@dlt.resource
async def get_users():
# Assuming _get_users is an async generator
async for u in _get_users(): # ty: ignore[unresolved-reference]
yield u
Please find more details in extract performance
Customize resources
Filter, transform, and pivot data
You can attach any number of transformations that are evaluated on an item-per-item basis to your resource. The available transformation types:
- map - transform the data item (
resource.add_map). - filter - filter the data item (
resource.add_filter). - yield map - a map that returns an iterator (so a single row may generate many rows -
resource.add_yield_map). - metrics - collect custom metrics without modifying data items (
resource.add_metrics).
Example: We have a resource that loads a list of users from an API endpoint. We want to customize it so:
- We track how many users with
user_id == "me"are being filtered out. - We remove users with
user_id == "me". - We anonymize user data.
Here's our resource:
import dlt
@dlt.resource(write_disposition="replace")
def users():
users = requests.get("https://pokeapi.co/api/v2/")
yield users
Here's our script that defines transformations and loads the data:
from pipedrive import users
def anonymize_user(user_data):
user_data["user_id"] = _hash_str(user_data["user_id"])
user_data["user_email"] = _hash_str(user_data["user_email"])
return user_data
def track_filtered(items, meta, metrics):
"""Track how many 'me' users were filtered out."""
users_list = items if isinstance(items, list) else [items]
for user in users_list:
if user["user_id"] == "me":
metrics["filtered_me_users"] = metrics.get("filtered_me_users", 0) + 1
# add metrics, filter, and anonymize function to users resource
for user in (
users()
.add_metrics(track_filtered)
.add_filter(lambda user: user["user_id"] != "me")
.add_map(anonymize_user)
):
print(user)