Ingest Google Analytics 4 into MotherDuck with dlt as a Flight
I want scheduled GA4 reporting data in MotherDuck without hand-writing API calls or INSERTs. Help me adapt the "Ingest Google Analytics 4 into MotherDuck with dlt as a Flight" recipe to my own data and use case, using it as a guide: https://motherduck.com/docs/cookbook/flight-dlt-ga4-ingest
A single-file Flight that runs a dlt
pipeline pulling Google Analytics 4 (GA4) report data into MotherDuck. It is
the GA4 adaptation of flight-dlt-ingest: the demo
repo_rows() source is replaced with a ga4_rows() source that calls the GA4
Data API, and the rest of the pattern — dlt-managed schema, Parquet loader files,
a run ledger, and Flight scheduling — is unchanged.
Everything is driven by Flight config, so you adapt it by setting config values,
not by editing flight.py. The defaults pull the last 7 days of
sessions/users/pageviews by default channel group from the GA4 property you set
in GA4_PROPERTY_ID, and load it into ga4_ingest.ga4.ga4_report in your own
account.
Aggregated reports, not raw events
The GA4 Data API (runReport) returns pre-aggregated report rows — metrics
like sessions and totalUsers broken down by dimensions like date and
sessionDefaultChannelGroup. It cannot return raw, event-level data
(individual event_name rows, event params, user properties).
- Want dashboards / KPI reporting? This template is the right fit.
- Want raw GA4 events? Use the native GA4 → BigQuery export and ingest with the GA4 BigQuery Flight instead.
How it works
flight.py runs a fixed sequence; the config values only change its inputs:
- Set
HOME=/tmp(dlt writes working files underHOME, and a Flight has a writable/tmp) and point the dlt MotherDuck destination atDESTINATION_DATABASEthrough an environment variable, so no token is written anywhere. - Connect to MotherDuck (
md:) andCREATE DATABASE IF NOT EXISTSthe destination, because dlt creates the dataset and tables but not the database. - Build a dlt pipeline and
run()thega4_rows()source withloader_file_format="parquet". In merge mode, the date is the merge key and the configured dimension columns are the primary key. - Append one row to the run ledger capturing the dlt load package summary.
ga4_rows() authenticates with a Google service account, calls the GA4 Data API
runReport for the configured property, dimensions, metrics, and date range, and
yields one dict per report row. It requests the GA4 maximum of 250,000 rows per
page, then continues until it reads the full row_count.
Why this dlt setup
The important default is the load format. For MotherDuck, prefer Parquet loader
files over row-wise insert_values, so larger sources stay on a bulk-loading
path. The Flight makes that choice explicit with loader_file_format="parquet".
The second important default is a date-partition merge over a moving lookback
window. GA4 keeps revising recent days as attribution and conversion windows
settle. Re-pulling 7daysAgo→yesterday replaces each returned date partition.
For each returned date, that corrects changed rows and removes rows that
disappear from a revised report.
append would double-count, while replace would discard history.
Adapt the pattern
- Set
GA4_PROPERTY_IDto your numeric GA4 property id. - Choose your grain with
GA4_DIMENSIONSandGA4_METRICS. KeepdateinGA4_DIMENSIONSwhen using the default merge mode. The dimension list is the primary key, so adding a dimension later changes the table grain. - Tune the lookback with
GA4_START_DATE/GA4_END_DATE. GA4 accepts relative strings (7daysAgo,yesterday,today) as well asYYYY-MM-DD. - Use
WRITE_DISPOSITION=mergewithdateplus the other dimension columns asPRIMARY_KEYfor date-partition replacement. Usereplaceif you re-pull the full range each run, orappendonly for an immutable pull log. - Keep
loader_file_format="parquet"unless you have measured a reason to change it. See the dlt MotherDuck destination docs.
Questions to answer
- Which GA4 property (numeric id), and which dimensions/metrics (the grain)?
- What lookback window matches how late your GA4 data settles?
- Target MotherDuck database and dataset (
DESTINATION_DATABASE,DATASET_NAME); is letting the Flight create the database acceptable? - Load behavior: date-partition
mergewith dimensions as the row key (default),replace, orappend? - Which service account token, and is the GA4 service-account key stored as a Flights secret (not config)?
- What schedule (cron) should it run on?
Prerequisites (google side)
- Enable the Google Analytics Data API in a GCP project.
- Create a service account and download its JSON key.
- In GA4 Admin → Property Access Management, add the service account's email
as a Viewer on the property. (API enabled but service account not granted
on the property is the most common cause of a
403.) - Note the numeric GA4 Property ID (Admin → Property Settings), e.g.
123456789— not theG-XXXXXXXmeasurement id.
Caveats
- Aggregated, not raw. See Aggregated reports, not raw events.
- dlt does not create the database. It creates the dataset (schema) and
tables, so the Flight pre-creates
DESTINATION_DATABASEwithCREATE DATABASE IF NOT EXISTS. mergeneedsdateand a primary key. WithWRITE_DISPOSITION=merge,GA4_DIMENSIONSmust includedateso the Flight can replace each returned date partition.PRIMARY_KEYdefaults to the dimension columns. Keep it aligned with your grain or switch toappend/replace.- Keep source credentials out of config. The GA4 service-account key is a
secret. Add a
GA4_SERVICE_ACCOUNT_JSONparameter to a MotherDuck Flights secret, then attach that secret to the Flight throughflight_secret_names. The Flight reads the attached, prefixed environment variable. - Keep the token out of config. The runtime attaches a MotherDuck token and
injects it as
MOTHERDUCK_TOKEN; never place a token inconfig.
What you'll adjust
Every knob is a config/env value read at the top of flight.py. Set them as
Flight config, not by editing code. The source itself lives in the ga4_rows()
function.
| Config key | Default | Purpose |
|---|---|---|
GA4_PROPERTY_ID | (required) | Numeric GA4 property id, e.g. 123456789. Validated as digits. |
GA4_DIMENSIONS | date,sessionDefaultChannelGroup | Comma-separated GA4 dimension API names. Defines the grain. Must include date in merge mode. |
GA4_METRICS | sessions,totalUsers,screenPageViews | Comma-separated GA4 metric API names. The Flight preserves integer and currency values, and loads other metric types as doubles. |
GA4_START_DATE | 7daysAgo | Report start date. Relative (NdaysAgo, yesterday, today) or YYYY-MM-DD. |
GA4_END_DATE | yesterday | Report end date. Same formats as GA4_START_DATE. |
DESTINATION_DATABASE | ga4_ingest | MotherDuck database dlt loads into. Created if missing. Validated as a SQL identifier. |
DATASET_NAME | ga4 | dlt dataset (schema) that holds the loaded tables. |
TABLE_NAME | ga4_report | dlt table name for the loaded rows. |
WRITE_DISPOSITION | merge | merge (default; replaces returned date partitions), append, or replace. |
PRIMARY_KEY | GA4_DIMENSIONS | Row key in merge mode. Defaults to the dimension columns. |
PIPELINE_NAME | ga4_dlt_ingest | dlt pipeline name (also used for dlt state). |
RUN_LEDGER_TABLE | dlt_ingest_runs | Audit table in the database's main schema. Validated as a SQL identifier. |
GA4_SERVICE_ACCOUNT_JSON | (Flights secret) | Service-account key JSON. Store as a Flights secret, never in config. |
MOTHERDUCK_TOKEN | (Flight-injected) | Auth. Select a token on the Flight; never put it in config. |
Run it
You need a MotherDuck account and access token, a GA4 property, and a service account with Viewer access on that property (see Prerequisites).
To smoke-test the pipeline locally before deploying, run the file directly against your account, supplying the GA4 key inline:
export MOTHERDUCK_TOKEN=your_token_here
export GA4_SERVICE_ACCOUNT_JSON="$(cat service-account.json)"
GA4_PROPERTY_ID=123456789 uv run --with-requirements requirements.txt flight.py
That single run creates the ga4_ingest database, loads the last 7 days of the
default report into ga4.ga4_report, and writes one ledger row. Override any
default inline, for example:
GA4_PROPERTY_ID=123456789 \
GA4_DIMENSIONS=date,country,deviceCategory \
GA4_METRICS=sessions,engagedSessions,conversions \
uv run --with-requirements requirements.txt flight.py
Deploy as a Flight
Create the Flight with the MD_CREATE_FLIGHT SQL function (no deploy SQL is
checked in; adapt the arguments to your situation), passing:
name: a Flight name, for examplega4_dlt_ingestsource_code: the contents offlight.pyrequirements_txt: the contents ofrequirements.txtflight_secret_names:["ga4_creds"]so theGA4_SERVICE_ACCOUNT_JSONparameter is injected asga4_creds_GA4_SERVICE_ACCOUNT_JSONconfig: the keys from What you'll adjust you want to override (at minimumGA4_PROPERTY_ID; omit any you are keeping at default)
Before the first run, create the ga4_creds Flights secret with a
GA4_SERVICE_ACCOUNT_JSON parameter. You can create it in
Settings > Secrets or with
CREATE SECRET ... (TYPE flights, ...). Then attach it through
flight_secret_names and select a MotherDuck token. The runtime injects the
token as MOTHERDUCK_TOKEN.
Create the Flight without a schedule first, trigger one manual run with
MD_RUN_FLIGHT(flight_id := ...) (the id is returned by MD_CREATE_FLIGHT and
listed by MD_FLIGHTS()), and confirm it succeeds and the GA4 tables and ledger
row appear. Once the manual run is green, add a daily schedule (15 7 * * *,
07:15 UTC, is a reasonable default) by updating the Flight's schedule_cron with
MD_UPDATE_FLIGHT. Schedule updates are metadata-only and do not create a new
Flight version.
Security
- Identifier validation.
DESTINATION_DATABASEandRUN_LEDGER_TABLEflow intoCREATE/INSERTstatements that cannot be parameterized, so each is checked against^[A-Za-z_][A-Za-z0-9_]*$before any SQL runs.GA4_PROPERTY_IDis validated as digits. - Parameterized data. The ledger row (pipeline name, dataset, table, and load summary) is written with bound parameters, never string-formatted into SQL.
- Secret handling. The GA4 service-account key is read from a prefixed environment variable supplied by an attached Flights secret, or the unprefixed local value during development. It is never written to disk or placed in config.
Learn more
- Flight mechanics (creating, running, scheduling): use the MotherDuck MCP
get_flight_guidetool. - dlt sources, write dispositions, and the MotherDuck destination: dlt MotherDuck destination docs.
- GA4 Data API dimensions and metrics: GA4 Dimensions & Metrics reference.
- The base template this adapts: flight-dlt-ingest.
- For raw event-level GA4 data: the GA4 BigQuery Flight using the GA4 → BigQuery export.
- Files in this template:
flight.py(the single-file Flight source) andrequirements.txt(duckdb,dlt[motherduck],google-analytics-data).