Build advanced dlt pipeline from scratch
This tutorial introduces you to foundational dlt concepts, demonstrating how to build a custom data pipeline that loads data from pure Python data structures to DuckDB. It starts with a simple example and progresses to more advanced topics and usage scenarios.
What you will learn
- Loading data from a list of Python dictionaries into DuckDB.
- Low-level API usage with a built-in HTTP client.
- Understand and manage data loading behaviors.
- Incrementally load new data and deduplicate existing data.
- Dynamic resource creation and reducing code redundancy.
- Group resources into sources.
- Securely handle secrets.
- Make reusable data sources.
Prerequisites
- Python 3.10 or higher installed
- Virtual environment set up
Installing dlt
Before we start, make sure you have a Python virtual environment set up. Follow the instructions in the installation guide to create a new virtual environment and install dlt.
Verify that dlt is installed by running the following command in your terminal:
dlt --version
Quick start
For starters, let's load a list of Python dictionaries into DuckDB and inspect the created dataset. Here is the code:
import dlt
data = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
pipeline = dlt.pipeline(
pipeline_name="quick_start", destination="duckdb", dataset_name="mydata"
)
load_info = pipeline.run(data, table_name="users")
print(load_info)
When you look at the code above, you can see that we:
- Import the
dltlibrary. - Define our data to load.
- Create a pipeline that loads data into DuckDB. Here we also specify the
pipeline_nameanddataset_name. We'll use both in a moment. - Run the pipeline.
Save this Python script with the name quick_start_pipeline.py and run the following command:
python quick_start_pipeline.py
The output should look like:
Pipeline quick_start completed in 0.59 seconds
1 load package(s) were loaded to destination duckdb and into dataset mydata
The duckdb destination used duckdb:////home/user-name/quick_start/quick_start.duckdb location to store data
Load package 1692364844.460054 is LOADED and contains no failed jobs
dlt just created a database schema called mydata (the dataset_name) with a table users in it.
Explore data in Python
You can use dlt datasets to easily query the data in pure Python.
# get the dataset
dataset = pipeline.dataset()
# get the user relation
table = dataset.users
# query the full table as dataframe
print(table.df())
# query the first 10 rows as arrow table
print(table.limit(10).arrow())
Explore data in the dashboard
To allow a sneak peek and basic discovery, you can take advantage of built-in integration with the marimo dashboard app:
dlt pipeline quick_start show
quick_start is the name of the pipeline from the script above. If you do not have marimo installed yet, do:
pip install marimo
Now you should see the users table in the dataset explorer data section of the dashboard app.
dlt works in Jupyter Notebook and Google Colab! See our Quickstart Colab Demo.
Looking for the source code of all the snippets? You can find and run them from this repository.
Now that you have a basic understanding of how to get started with dlt, you might be eager to dive deeper. For that, we need to switch to a more advanced data source - the GitHub API. We will load issues from our dlt-hub/dlt repository.
This tutorial uses the GitHub REST API for demonstration purposes only. If you need to read data from a REST API, consider using dlt's REST API source. Check out the REST API source tutorial for a quick start or the REST API source reference for more details.
Create a pipeline
First, we need to create a pipeline. Pipelines are the main building blocks of dlt and are used to load data from sources to destinations. Open your favorite text editor and create a file called github_issues.py. Add the following code to it:
import dlt
from dlt.sources.helpers import requests
# Specify the URL of the API endpoint
url = "https://api.github.com/repos/dlt-hub/dlt/issues"
# Make a request and check if it was successful
response = requests.get(url)
response.raise_for_status()
pipeline = dlt.pipeline(
pipeline_name="github_issues",
destination="duckdb",
dataset_name="github_data",
)
# The response contains a list of issues
pipeline.run(response.json(), table_name="issues")
Here's what the code above does:
- It makes a request to the GitHub API endpoint and checks if the response is successful.
- Then, it creates a dlt pipeline with the name
github_issuesand specifies that the data should be loaded to theduckdbdestination and thegithub_datadataset. Nothing gets loaded yet. - Finally, it runs the pipeline with the data from the API response (
response.json()) and specifies that the data should be loaded to theissuestable. Therunmethod returns aLoadInfoobject that contains information about the loaded data.
Run the pipeline
Save github_issues.py and run the following command:
python github_issues.py
Once the data has been loaded, you can inspect the created dataset using the dashboard app:
dlt pipeline github_issues show
Append or replace your data
Try running the pipeline again with python github_issues.py. You will notice that the issues table contains two copies of the same data. This happens because the default load mode is append. It is very useful, for example, when you have daily data updates and you want to ingest them.
To get the latest data, we'd need to run the script again. But how to do that without duplicating the data?
One option is to tell dlt to replace the data in existing tables in the destination by using the replace write disposition. Change the github_issues.py script to the following:
import dlt
from dlt.sources.helpers import requests
# Specify the URL of the API endpoint
url = "https://api.github.com/repos/dlt-hub/dlt/issues"
# Make a request and check if it was successful
response = requests.get(url)
response.raise_for_status()
pipeline = dlt.pipeline(
pipeline_name='github_issues',
destination='duckdb',
dataset_name='github_data',
)
# The response contains a list of issues
load_info = pipeline.run(
response.json(),
table_name="issues",
write_disposition="replace" # <-- Add this line
)
print(load_info)
Run this script twice to see that the issues table still contains only one copy of the data.
What if the API has changed and new fields get added to the response?
dlt will migrate your tables!
See the replace mode and table schema migration in action in our Schema evolution colab demo.
Learn more:
Declare loading behavior
So far, we have been passing the data to the run method directly. This is a quick way to get started. However, frequently, you receive data in chunks, and you want to load it as it arrives. For example, you might want to load data from an API endpoint with pagination or a large file that does not fit in memory. In such cases, you can use Python generators as a data source.
You can pass a generator to the run method directly or use the @dlt.resource decorator to turn the generator into a dlt resource. The decorator allows you to specify the loading behavior and relevant resource parameters.
Load only new data (incremental loading)
Let's improve our GitHub API example and get only issues that were created since the last load.
Instead of using the replace write disposition and downloading all issues each time the pipeline is run, we do the following:
from typing import Any, Iterator
import dlt
from dlt.sources.helpers import requests
@dlt.resource(table_name="issues", write_disposition="append")
def get_issues(
created_at: dlt.sources.incremental[str] = dlt.sources.incremental(
"created_at", initial_value="1970-01-01T00:00:00Z"
),
) -> Iterator[dict[str, Any]]:
# NOTE: we read only open issues to minimize number of calls to the API.
# There's a limit of ~50 calls for not authenticated Github users.
url = (
"https://api.github.com/repos/dlt-hub/dlt/issues"
"?per_page=100&sort=created&directions=desc&state=open"
)
while True:
response = requests.get(url)
response.raise_for_status()
yield response.json()
# Stop requesting pages if the last element was already
# older than initial value
# Note: incremental will skip those items anyway, we just
# do not want to use the api limits
if created_at.start_out_of_range:
break
# get next page
if "next" not in response.links:
break
url = response.links["next"]["url"]
pipeline = dlt.pipeline(
pipeline_name="github_issues_incremental",
destination="duckdb",
dataset_name="github_data_append",
)
load_info = pipeline.run(get_issues)
normalize_info = pipeline.last_trace.last_normalize_info # ty: ignore[unresolved-attribute]
# print(normalize_info.row_counts)
"""
{
'issues': 420,
'issues__labels': 379,
'issues__assignees': 138,
'issues__performed_via_github_app__events': 48,
'_dlt_pipeline_state': 1,
}
"""
Let's take a closer look at the code above.
We use the @dlt.resource decorator to declare the table name into which data will be loaded and specify the append write disposition.
We request issues for the dlt-hub/dlt repository ordered by the created_at field (descending) and yield them page by page in the get_issues generator function.
We also use dlt.sources.incremental to track the created_at field present in each issue to filter in the newly created ones.
Now run the script. It loads all the issues from our repo to duckdb. Run it again, and you can see that no issues got added (if no issues were created in the meantime).
Now you can run this script on a daily schedule, and each day you’ll load only issues created after the time of the previous pipeline run.
Between pipeline runs, dlt keeps the state in the same database it loaded data into.
Peek into that state, the tables loaded, and get other information with:
dlt pipeline -v github_issues_incremental info
Learn more:
- Declare your resources and group them in sources using Python decorators.
- Set up "last value" incremental loading.
- Inspect pipeline after loading.
dltcommand line interface.