Skip to main content

JSONL for Analytics Pipelines

Power your ETL workflows, data warehouses, and analytics at scale with JSONL - the format trusted by BigQuery, Snowflake, and modern data platforms

Why JSONL for Analytics?

Perfect for Data Warehouses

JSONL is the preferred format for loading data into modern data warehouses. Its line-based structure allows for parallel loading, schema-on-read flexibility, and efficient incremental updates.

  • Parallel data loading
  • Schema evolution support
  • Incremental updates
  • Nested data structures

Platform Support

  • BigQuery: Native JSONL import
  • Snowflake: JSONL bulk loading
  • Redshift: COPY from S3 JSONL
  • Athena: Query JSONL directly
  • Databricks: Delta Lake ingestion
  • DuckDB: Query files in place with SQL
  • Polars: Lazy scans with pushdown

The Honest Framing: Great Interchange, Poor Storage

JSONL is an excellent interchange and landing format, and a poor long-term analytical storage format. It wins at the edges of a pipeline: it appends without rewriting, splits at any newline, survives a partial write, and every warehouse ingests it natively. It loses in the middle, where the same rows are scanned repeatedly, because a row-oriented text format must parse every byte of every record to answer a query touching two columns out of forty, and carries no statistics to skip on. So land raw JSONL, keep it immutable as the replayable record of what arrived, and convert it once into a columnar table for anything queried more than a handful of times. The format comparison covers that tradeoff in detail.

ETL Workflows with JSONL

Extract - Pull Data to JSONL

# Python: Extract from PostgreSQL to JSONL
import psycopg2
import json
from datetime import datetime

conn = psycopg2.connect("dbname=mydb user=postgres")
cursor = conn.cursor()

# Query with cursor for memory efficiency
cursor.execute("SELECT * FROM users WHERE created_at > %s", (last_sync,))

with open(f"users_{datetime.now().strftime('%Y%m%d')}.jsonl", 'w') as f:
    while True:
        rows = cursor.fetchmany(1000)  # Batch processing
        if not rows:
            break

        for row in rows:
            record = {
                'id': row[0],
                'email': row[1],
                'name': row[2],
                'created_at': row[3].isoformat()
            }
            f.write(json.dumps(record) + '\n')

cursor.close()
conn.close()

# Extract from MongoDB
from pymongo import MongoClient

client = MongoClient('mongodb://localhost:27017/')
db = client['myapp']

with open('orders.jsonl', 'w') as f:
    for doc in db.orders.find({'status': 'completed'}):
        doc['_id'] = str(doc['_id'])  # Convert ObjectId
        f.write(json.dumps(doc) + '\n')

# Extract from REST API
import requests

url = "https://api.example.com/events"
params = {'since': '2026-01-01', 'limit': 1000}

with open('api_events.jsonl', 'w') as f:
    while True:
        response = requests.get(url, params=params)
        data = response.json()

        for item in data['items']:
            f.write(json.dumps(item) + '\n')

        if not data.get('next_page'):
            break
        params['page'] = data['next_page']

Gotchas. Never materialize the whole result set - fetchmany streams rows in batches. Convert values JSON cannot represent first, because Mongo ObjectId, Postgres Decimal, UUIDs, and dates all raise TypeError. Normalize timestamps to UTC ISO 8601 during extraction, paginate on a monotonic key rather than an offset, and force UTF-8 on the output file.

Transform - Process JSONL Data

# Python: Transform JSONL with pandas
import pandas as pd
import json

# Read JSONL into DataFrame
records = []
with open('users.jsonl', 'r') as f:
    for line in f:
        records.append(json.loads(line))

df = pd.DataFrame(records)

# Transformations
df['created_date'] = pd.to_datetime(df['created_at']).dt.date
df['email_domain'] = df['email'].str.split('@').str[1]
df['name_length'] = df['name'].str.len()

# Enrich with external data
df = df.merge(companies_df, left_on='email_domain', right_on='domain')

# Filter and clean
df = df[df['email'].str.contains('@')]  # Valid emails
df = df.drop_duplicates(subset=['email'])

# Write transformed data
with open('users_transformed.jsonl', 'w') as f:
    for record in df.to_dict('records'):
        f.write(json.dumps(record) + '\n')

# Streaming transformation for large files
def transform_record(record):
    """Transform individual record"""
    record['email'] = record['email'].lower()
    record['created_year'] = record['created_at'][:4]
    return record

with open('input.jsonl', 'r') as infile, \
     open('output.jsonl', 'w') as outfile:
    for line in infile:
        record = json.loads(line)
        transformed = transform_record(record)
        outfile.write(json.dumps(transformed) + '\n')

Gotchas. A DataFrame is bounded by memory; the line-at-a-time loop is not. Pandas also changes types on the round trip: one null in an integer column makes it a float column, so ids come back out as 1.0, and missing fields become a bare NaN that is not valid JSON. Write to a temporary file and rename it into place on success; never transform in place.

Load - Import to Data Warehouse

# Load to PostgreSQL
import psycopg2
import json

conn = psycopg2.connect("dbname=warehouse")
cursor = conn.cursor()

with open('users_transformed.jsonl', 'r') as f:
    for line in f:
        record = json.loads(line)
        cursor.execute(
            """
            INSERT INTO users (id, email, name, created_at)
            VALUES (%s, %s, %s, %s)
            ON CONFLICT (id) DO UPDATE
            SET email = EXCLUDED.email,
                name = EXCLUDED.name,
                updated_at = NOW()
            """,
            (record['id'], record['email'], record['name'], record['created_at'])
        )

conn.commit()
cursor.close()

# Bulk load with COPY
with open('users.jsonl', 'r') as f:
    cursor.copy_expert(
        """
        COPY users (data)
        FROM STDIN
        """,
        f
    )

When not to use this. Row-by-row INSERT is the slowest load path and the easiest to write; stream into a staging table with COPY and run one set-based MERGE instead. And if the destination is a cloud warehouse, do not push rows from the ETL host at all - write the JSONL to a bucket and hand the warehouse a URI.

Query JSONL in Place with SQL

The biggest change in analytics tooling since the classic extract-transform-load pipeline was designed is that you often do not have to load anything. DuckDB and Polars read JSONL directly off disk or object storage, infer a schema, and answer real analytical queries with no server, no cluster to size, and no import step.

DuckDB - SQL Straight Against the File

DuckDB is an in-process analytical database: it runs inside your Python process or as a single CLI binary, with nothing to administer. read_ndjson treats a JSONL file as a table - no CREATE TABLE, no COPY, no staging step. read_json_auto does the same while also guessing whether the file is newline-delimited or a JSON array.

-- DuckDB CLI: no server, no import, no schema declaration
SELECT * FROM read_ndjson('data.jsonl') LIMIT 5;

-- A real aggregate over the raw file
SELECT
    json_extract_string(metadata, '$.country') AS country,
    count(*)    AS events,
    sum(amount) AS revenue
FROM read_ndjson('events/*.jsonl')
WHERE event_type = 'purchase'
GROUP BY 1
ORDER BY revenue DESC;

-- Let DuckDB detect the layout (array vs newline-delimited)
SELECT * FROM read_json_auto('unknown_shape.json');

-- Inspect what schema inference decided, then widen the sample if needed
DESCRIBE SELECT * FROM read_ndjson('events.jsonl');
SELECT * FROM read_ndjson('events.jsonl', sample_size = -1);

-- Skip malformed lines, and record which file each row came from
SELECT filename, count(*)
FROM read_ndjson('landing/*.jsonl', ignore_errors = true, filename = true)
GROUP BY 1;

Automatic schema inference is what makes this effortless and is also the thing to watch: DuckDB samples the file to decide types, so a column that is an integer for the first several thousand lines and a string afterwards gets inferred as BIGINT and then fails. Use sample_size = -1 on untrusted input, or declare columns explicitly for anything scheduled.

Querying Object Storage and Writing Parquet

The httpfs extension extends the same table functions to s3://, gs://, and HTTPS URLs, so a bucket of JSONL is queryable without downloading it. Paired with COPY ... TO ... (FORMAT PARQUET), DuckDB is also the shortest path from a raw landing zone to a columnar table.

-- Once per database: enable object storage access
INSTALL httpfs;
LOAD httpfs;
CREATE SECRET s3_landing (TYPE s3, PROVIDER credential_chain, REGION 'us-east-1');

-- Query a whole prefix of JSONL without downloading it
SELECT event_type, count(*) AS n
FROM read_ndjson('s3://<bucket>/landing/events/2026/07/*.jsonl')
GROUP BY 1;

-- Convert raw JSONL to partitioned Parquet in one statement
COPY (
    SELECT
        cast(user_id AS BIGINT) AS user_id,
        event_type,
        cast(ts AS TIMESTAMP)   AS event_ts,
        cast(ts AS DATE)        AS event_date,
        amount
    FROM read_ndjson('s3://<bucket>/landing/events/*.jsonl', union_by_name = true)
)
TO 's3://<bucket>/curated/events'
(FORMAT PARQUET, COMPRESSION ZSTD, PARTITION_BY (event_date), OVERWRITE_OR_IGNORE);

Queries against that Parquet copy skip whole files through partition pruning, skip row groups using column statistics, and read only the columns named in the SELECT. The JSONL original can do none of that, because a text file has no footer and no statistics. That is the whole argument for converting, and it costs one COPY statement.

Polars - Lazy Scans, Predicate and Projection Pushdown

pl.read_ndjson() is eager: it parses the entire file into memory, then filters. pl.scan_ndjson() is lazy: it returns a LazyFrame, which is a query plan rather than data, and nothing is read until collect(). By then Polars has seen the whole chain and can rewrite it. Projection pushdown means it never builds columns the query does not use; predicate pushdown means the filter runs as rows are read, so rejected rows never occupy memory. A lazy scan of a file far larger than RAM can answer a narrow question where the eager version fails outright.

import polars as pl

# EAGER - parses the whole file into memory first, then filters.
# Fine for a small file, fatal for a large one.
df = pl.read_ndjson("events.jsonl")
result = df.filter(pl.col("amount") > 100).select(["user_id", "amount"])

# LAZY - builds a plan; nothing is read until collect()
result = (
    pl.scan_ndjson("landing/events_*.jsonl")      # globs are one logical table
    .filter(pl.col("event_type") == "purchase")   # predicate pushdown
    .select(["user_id", "amount"])                # projection pushdown
    .group_by("user_id")
    .agg(pl.col("amount").sum().alias("revenue"))
    .collect()
)

# .explain() prints the optimized plan, showing what was pushed into the scan
# Stream JSONL straight to Parquet without ever holding it in memory
(pl.scan_ndjson("landing/events_*.jsonl")
   .with_columns(pl.col("ts").str.to_datetime().alias("event_ts"))
   .sink_parquet("curated/events.parquet", compression="zstd"))

Be honest about the limits on a text format: JSONL has no footer, so Polars still walks every byte to find line boundaries. Pushdown saves materialization, not I/O - often the difference between a job that finishes and one killed by the out-of-memory reaper, but not the skipping a columnar format can do.

When Not to Query JSONL Directly

In-place querying is right when the data is read once or twice, or when the point is to avoid standing up infrastructure. It is wrong the moment the same file becomes something people query every day.

  • Dashboards. Every refresh re-parses every byte; convert once and pay the parsing cost once.
  • Point lookups. No index, no sort order. One row by id still means reading the file.
  • Concurrent readers. Ten analysts scanning the same prefix pay the scan ten times, and a cloud warehouse bills each one.

Count readers. One reader, once: query in place. Many readers, repeatedly: convert. The format comparison covers where JSONL sits against Parquet, Avro, and CSV.

Where JSONL Fits in a Lakehouse

A lakehouse is a table format such as Apache Iceberg or Delta Lake layered over files in object storage, giving a data lake what it always lacked: atomic commits, schema evolution, and time travel. JSONL's role in it is specific. JSONL is the raw landing format; the Iceberg table is what gets queried. Confusing those two jobs is the most expensive mistake in a lakehouse build.

The Landing Zone Pattern

Producers write JSONL into a dated prefix exactly as it arrived, with no validation beyond checking that each line is valid JSON. Nothing is overwritten or edited. That immutability is the point: when a transformation turns out to have been wrong three weeks ago, you fix it and replay from the landing zone instead of asking the upstream system for the data again.

JSONL suits this layer for reasons unrelated to query speed. It appends without rewriting a footer, so a writer can flush continuously. It splits at any newline, so a crashed producer leaves a file valid up to the last complete record. It needs no schema agreed in advance. And at three in the morning you can read it with head and grep.

# Three layers in one bucket
s3://<bucket>/landing/     # raw JSONL, immutable, append only
    events/dt=2026-07-25/hour=14/events-00001.jsonl.gz
s3://<bucket>/warehouse/   # Iceberg tables, Parquet data files
    analytics.db/events/{data,metadata}/...
s3://<bucket>/quarantine/  # lines that failed parsing or validation
    events/dt=2026-07-25/rejects.jsonl

Partition by ingestion time rather than a business field, because a replay selects on ingestion time. Compress with gzip and keep objects large enough to parallelize over. And route unparseable lines to the quarantine prefix instead of dropping them, so a malformed producer shows up as a growing directory rather than silently missing revenue.

Compacting JSONL into Iceberg Tables

The compaction job is the bridge. It reads a window of raw JSONL, casts fields to declared types, quarantines records that fail validation, and appends the result to an Iceberg table as Parquet data files. Iceberg commits are atomic, so readers see the old snapshot or the new one and never a partially written batch - which is what lets the job run while analysts are querying.

-- Spark SQL with the Iceberg catalog configured
CREATE TABLE IF NOT EXISTS analytics.events (
    event_id STRING, user_id BIGINT, event_type STRING,
    amount DECIMAL(12,2), event_ts TIMESTAMP
)
USING iceberg
PARTITIONED BY (days(event_ts));   -- hidden partitioning, no extra column

-- Compact one hour of raw JSONL into the table
INSERT INTO analytics.events
SELECT event_id, cast(user_id AS BIGINT), event_type,
       cast(amount AS DECIMAL(12,2)), cast(ts AS TIMESTAMP)
FROM json.`s3://<bucket>/landing/events/dt=2026-07-25/hour=14/`
WHERE event_id IS NOT NULL;

-- Housekeeping: merge small files, expire old snapshots
CALL analytics.system.rewrite_data_files(table => 'events');

-- Time travel comes for free once the data is in a table format
SELECT count(*) FROM analytics.events VERSION AS OF 1234567890123456789;

Spark is not required. PyIceberg does the same append from plain Python: build the batch with pl.scan_ndjson(...).collect().to_arrow() and call table.append(batch) against a REST, Glue, or Nessie catalog.

Why a Table Format and Not Just a Folder of Parquet

  • Atomic commits. A batch appears all at once; no reader catches a half-written partition.
  • Schema evolution. Columns are added, renamed, or dropped without rewriting data files, because Iceberg tracks columns by id rather than position.
  • Time travel and rollback. Every commit is a snapshot, so a bad load is reverted rather than reconstructed.
  • Row-level deletes. Corrections and privacy deletion requests become a MERGE, not a partition rewrite.

Converting JSONL to Parquet fixes the scanning problem; Iceberg fixes the operational problems that show up next. The landing zone still earns its place, because it is the only copy that reflects exactly what the upstream system sent, and it is what a replay reads when the table has to be rebuilt.

Google BigQuery

BigQuery calls the format NEWLINE_DELIMITED_JSON and treats it as a first-class load source. Three ways in, with different cost and latency characteristics: batch load jobs from Cloud Storage, the streaming insert API, and external tables that leave the files where they are.

Loading JSONL into BigQuery

# Python: Load JSONL to BigQuery
from google.cloud import bigquery

client = bigquery.Client()
table_id = "project.dataset.users"

job_config = bigquery.LoadJobConfig(
    source_format=bigquery.SourceFormat.NEWLINE_DELIMITED_JSON,
    autodetect=True,  # Auto-detect schema
    write_disposition=bigquery.WriteDisposition.WRITE_APPEND,
)

# Load from local file
with open("users.jsonl", "rb") as source_file:
    job = client.load_table_from_file(source_file, table_id, job_config=job_config)

job.result()  # Wait for completion
print(f"Loaded {job.output_rows} rows")

# Load from Google Cloud Storage
uri = "gs://my-bucket/data/*.jsonl"
job = client.load_table_from_uri(uri, table_id, job_config=job_config)
job.result()

# Streaming inserts (real-time)
rows_to_insert = [
    {"id": 1, "name": "Alice", "email": "[email protected]"},
    {"id": 2, "name": "Bob", "email": "[email protected]"}
]

errors = client.insert_rows_json(table_id, rows_to_insert)
if errors:
    print(f"Errors: {errors}")

# Query nested JSON
query = """
SELECT
    user_id,
    JSON_VALUE(metadata, '$.country') as country,
    JSON_VALUE(metadata, '$.city') as city
FROM `project.dataset.events`
WHERE DATE(timestamp) = CURRENT_DATE()
"""

results = client.query(query)
for row in results:
    print(f"{row.user_id}: {row.country}, {row.city}")

Choosing the load path. Per Google's BigQuery pricing documentation, batch loading into BigQuery storage is free while the streaming insert API is billed per volume ingested. That settles most designs: write JSONL to a bucket and run a load job, and reserve streaming inserts for genuinely real-time cases. An external table skips loading entirely, at the cost of a full parse on every query.

Gotchas. autodetect infers the schema from a sample rather than the whole file, so a field appearing only in later records can be missing from the table entirely - declare the schema explicitly for anything scheduled. Point WRITE_TRUNCATE at a partition rather than a whole table, or a retry quietly wipes history. And prefer many medium files over one enormous one, because BigQuery parallelizes the load across files.

Schema Design for JSON Data

Promote anything you filter, join, or group on into a real column, and leave open-ended payloads in a native JSON column so a new key needs no migration. Then partition on event date, because on-demand pricing bills bytes scanned and an unpartitioned table makes every query pay for the whole history.

-- Create table with schema
CREATE OR REPLACE TABLE `project.dataset.users` (
    id INT64,
    email STRING,
    name STRING,
    metadata JSON,  -- Native JSON type
    tags ARRAY<STRING>,
    address STRUCT<
        street STRING,
        city STRING,
        country STRING
    >,
    created_at TIMESTAMP
)
PARTITION BY DATE(created_at)
CLUSTER BY email;

-- Query JSON fields
SELECT
    id,
    JSON_EXTRACT_SCALAR(metadata, '$.signup_source') as signup_source,
    JSON_EXTRACT_ARRAY(metadata, '$.interests') as interests
FROM `project.dataset.users`
WHERE JSON_EXTRACT_SCALAR(metadata, '$.plan') = 'premium';

-- Flatten nested arrays
SELECT
    id,
    tag
FROM `project.dataset.users`,
UNNEST(tags) as tag;

Snowflake

Snowflake lands each record whole into a VARIANT column and lets you project typed columns out of it later with a view, so a producer can add a field without blocking the load. Snowflake documents that it stores VARIANT data in an internal columnar representation and extracts frequently accessed paths into separate internal columns, so reading a path does not perform like parsing text on every row.

Loading JSONL into Snowflake

-- Create file format for JSONL
CREATE OR REPLACE FILE FORMAT jsonl_format
    TYPE = 'JSON'
    STRIP_OUTER_ARRAY = FALSE
    COMPRESSION = 'GZIP';

-- Create stage for S3
CREATE OR REPLACE STAGE s3_stage
    URL = 's3://my-bucket/data/'
    CREDENTIALS = (AWS_KEY_ID = '...' AWS_SECRET_KEY = '...')
    FILE_FORMAT = jsonl_format;

-- Create target table
CREATE OR REPLACE TABLE users (
    raw_data VARIANT,  -- JSON data type
    loaded_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
);

-- Load data from stage
COPY INTO users (raw_data)
FROM @s3_stage/users.jsonl.gz
FILE_FORMAT = jsonl_format
ON_ERROR = 'CONTINUE';

-- Create view with parsed columns
CREATE OR REPLACE VIEW users_parsed AS
SELECT
    raw_data:id::NUMBER as id,
    raw_data:email::STRING as email,
    raw_data:name::STRING as name,
    raw_data:metadata::VARIANT as metadata,
    raw_data:created_at::TIMESTAMP as created_at,
    loaded_at
FROM users;

-- Query nested JSON
SELECT
    id,
    email,
    metadata:country::STRING as country,
    metadata:preferences[0]::STRING as first_preference
FROM users_parsed
WHERE metadata:plan::STRING = 'premium';

Gotchas. Leave STRIP_OUTER_ARRAY FALSE for true JSONL. ON_ERROR = 'CONTINUE' will load a batch that is mostly rejects without complaint, so check COPY_HISTORY after every run and alert on a nonzero error count. COPY INTO skips files it has already ingested, which gives idempotency for free but means re-loading a corrected file needs a new filename or FORCE = TRUE.

When not to leave it in VARIANT. Raw-plus-view is excellent for ingestion and poor as the permanent home of a heavily queried fact table. Once the shape settles, materialize the parsed view into typed columns with clustering keys.

Snowflake Python Connector

import snowflake.connector
import json

conn = snowflake.connector.connect(
    user='myuser',
    password='mypassword',
    account='myaccount',
    warehouse='COMPUTE_WH',
    database='MYDB',
    schema='PUBLIC'
)

cursor = conn.cursor()

# Stage local file
cursor.execute("PUT file://users.jsonl @~")

# Load into table
cursor.execute("""
    COPY INTO users (raw_data)
    FROM @~/users.jsonl.gz
    FILE_FORMAT = jsonl_format
""")

# Query data
cursor.execute("""
    SELECT raw_data:email::STRING as email
    FROM users
    WHERE raw_data:created_at::DATE = CURRENT_DATE()
""")

for row in cursor:
    print(row[0])

cursor.close()
conn.close()

Apache Airflow Orchestration

Design for idempotency: a task that runs twice for the same logical date must produce the same result as running once. Writing a file named after the interval it covers and overwriting it wholesale is naturally idempotent; appending to a shared file is not.

ETL DAG with JSONL

from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.providers.google.cloud.transfers.local_to_gcs import LocalFilesystemToGCSOperator
from airflow.providers.google.cloud.operators.bigquery import BigQueryInsertJobOperator
from datetime import datetime, timedelta

default_args = {
    'owner': 'data-team',
    'retries': 3,
    'retry_delay': timedelta(minutes=5)
}

dag = DAG(
    'etl_to_bigquery',
    default_args=default_args,
    schedule_interval='0 2 * * *',  # Daily at 2 AM
    start_date=datetime(2026, 1, 1),
    catchup=False
)

def extract_data(**context):
    """Extract data to JSONL"""
    import json
    import psycopg2

    conn = psycopg2.connect("postgresql://...")
    cursor = conn.cursor()

    execution_date = context['execution_date']
    filepath = f"/tmp/data_{execution_date.strftime('%Y%m%d')}.jsonl"

    cursor.execute("SELECT * FROM users WHERE updated_at::DATE = %s", (execution_date.date(),))

    with open(filepath, 'w') as f:
        for row in cursor:
            record = {'id': row[0], 'email': row[1], 'name': row[2]}
            f.write(json.dumps(record) + '\n')

    return filepath

def transform_data(**context):
    """Transform JSONL data"""
    import json

    filepath = context['task_instance'].xcom_pull(task_ids='extract')
    output_path = filepath.replace('.jsonl', '_transformed.jsonl')

    with open(filepath, 'r') as infile, open(output_path, 'w') as outfile:
        for line in infile:
            record = json.loads(line)
            record['email'] = record['email'].lower()
            record['processed_at'] = datetime.utcnow().isoformat()
            outfile.write(json.dumps(record) + '\n')

    return output_path

extract = PythonOperator(
    task_id='extract',
    python_callable=extract_data,
    dag=dag
)

transform = PythonOperator(
    task_id='transform',
    python_callable=transform_data,
    dag=dag
)

upload_to_gcs = LocalFilesystemToGCSOperator(
    task_id='upload_to_gcs',
    src="{{ task_instance.xcom_pull(task_ids='transform') }}",
    dst="data/{{ ds }}/users.jsonl",
    bucket='my-data-bucket',
    dag=dag
)

load_to_bq = BigQueryInsertJobOperator(
    task_id='load_to_bigquery',
    configuration={
        "load": {
            "sourceUris": ["gs://my-data-bucket/data/{{ ds }}/users.jsonl"],
            "destinationTable": {
                "projectId": "my-project",
                "datasetId": "analytics",
                "tableId": "users"
            },
            "sourceFormat": "NEWLINE_DELIMITED_JSON",
            "writeDisposition": "WRITE_APPEND",
            "autodetect": True
        }
    },
    dag=dag
)

extract >> transform >> upload_to_gcs >> load_to_bq

Gotchas. Writing intermediates to /tmp works only while every task runs on the same machine, which stops being true the moment you move to Celery or Kubernetes executors - push the file to object storage and pass the URI. Keep XCom for small values like that URI, because it is backed by the metadata database.

Dagster and Prefect - Asset-Centric Alternatives

Airflow is not the only option. Dagster is asset-oriented: you declare the data assets that should exist, such as a raw JSONL partition and the curated table derived from it, and Dagster works out execution order and tracks lineage. Prefect stays closer to ordinary Python, adding retries and observability to functions you already wrote.

# Dagster: declare the assets, not the task graph
from dagster import asset, AssetExecutionContext, DailyPartitionsDefinition
import polars as pl

daily = DailyPartitionsDefinition(start_date="2026-01-01")

@asset(partitions_def=daily)
def raw_events(context: AssetExecutionContext) -> str:
    day = context.partition_key
    return extract_to_jsonl(day, f"s3://<bucket>/landing/events/dt={day}/events.jsonl")

@asset(partitions_def=daily)
def curated_events(raw_events: str) -> None:
    pl.scan_ndjson(raw_events).sink_parquet(raw_events + ".parquet")

The payoff is backfills: when the unit of work is a declared asset partition, rerunning one bad day is a first-class operation and the lineage graph names what needs rebuilding.

dbt (Data Build Tool)

dbt does not load your JSONL; it owns everything afterwards as version-controlled SQL. Use a thin staging layer whose only job is unpacking the raw JSON payload into typed columns, and build marts exclusively on top of it, so a change in the incoming JSON shape is absorbed in exactly one file.

Transforming JSON in dbt

-- models/staging/stg_events.sql
-- Parse JSONL loaded into raw table

{{
    config(
        materialized='incremental',
        unique_key='event_id'
    )
}}

SELECT
    raw_data:event_id::STRING as event_id,
    raw_data:user_id::NUMBER as user_id,
    raw_data:event_type::STRING as event_type,
    raw_data:timestamp::TIMESTAMP as event_timestamp,
    raw_data:properties::VARIANT as properties,
    CURRENT_TIMESTAMP() as dbt_loaded_at
FROM {{ source('raw', 'events') }}

{% if is_incremental() %}
    WHERE raw_data:timestamp::TIMESTAMP > (SELECT MAX(event_timestamp) FROM {{ this }})
{% endif %}

-- models/marts/fct_user_events.sql
-- Aggregate user events

SELECT
    user_id,
    DATE(event_timestamp) as event_date,
    event_type,
    COUNT(*) as event_count,
    MIN(event_timestamp) as first_event_at,
    MAX(event_timestamp) as last_event_at
FROM {{ ref('stg_events') }}
GROUP BY 1, 2, 3

Incremental gotchas. Filtering on the maximum timestamp already loaded silently drops late-arriving records - not a hypothetical in an event pipeline. Subtract an overlap window from the high-water mark and let unique_key deduplicate the rows you reprocess. The incremental filter is also skipped entirely on the first run and on a full refresh, so a model that is correct only because of it produces different numbers depending on how it was invoked. Back the staging layer with unique, not_null, and accepted_values tests so a shape change fails the run before a mart is built on bad data.

Apache Spark

Spark's JSON reader expects newline-delimited JSON by default. Because records never span lines, Spark can split a large file at arbitrary byte offsets and hand each split to a different executor without coordination. Set multiLine to true and the parallelism disappears with it, because a pretty-printed JSON array must be read by one task from the beginning.

Processing JSONL with PySpark

from pyspark.sql import SparkSession
from pyspark.sql.functions import *

spark = SparkSession.builder.appName("JSONLProcessor").getOrCreate()

# Read JSONL
df = spark.read.json("s3://bucket/data/*.jsonl")

# Schema inference
df.printSchema()

# Transformations
transformed_df = df \
    .withColumn("created_date", to_date(col("created_at"))) \
    .withColumn("email_domain", split(col("email"), "@").getItem(1)) \
    .filter(col("status") == "active")

# Nested JSON operations
df_exploded = df \
    .select(
        col("id"),
        explode(col("tags")).alias("tag")
    )

# Aggregations
summary = df \
    .groupBy("email_domain") \
    .agg(
        count("*").alias("user_count"),
        avg("age").alias("avg_age")
    ) \
    .orderBy(desc("user_count"))

# Write to JSONL (partitioned)
transformed_df.write \
    .mode("overwrite") \
    .partitionBy("created_date") \
    .json("s3://bucket/output/")

# Write to Parquet for efficiency
transformed_df.write \
    .mode("overwrite") \
    .partitionBy("created_date") \
    .parquet("s3://bucket/parquet/")

Declare the schema in production. Inference costs an extra pass over the data before the real job starts, and it is nondeterministic: if a field is absent from every record in today's batch it is absent from today's schema, and a downstream job that selects it fails.

from pyspark.sql.types import StructType, StructField, StringType, LongType

schema = StructType([
    StructField("event_id",   StringType(), False),
    StructField("user_id",    LongType(),   True),
    StructField("event_type", StringType(), True),
    StructField("_corrupt_record", StringType(), True),
])

df = (spark.read
        .schema(schema)                   # no inference pass, deterministic
        .option("mode", "PERMISSIVE")     # keep bad lines instead of failing
        .option("columnNameOfCorruptRecord", "_corrupt_record")
        .json("s3://bucket/landing/events/*.jsonl"))

# Quarantine the failures, then continue with the clean rows
df.filter(df._corrupt_record.isNotNull()).write.mode("append").text("s3://bucket/quarantine/")
clean = df.filter(df._corrupt_record.isNull()).drop("_corrupt_record")

When not to use Spark at all. A cluster earns its overhead when the data genuinely exceeds one machine or the job needs a distributed shuffle. Below that, DuckDB or Polars on one large instance finishes the same transformation sooner and cheaper. Reach for Spark because the data demands it, not because it is nominally called big.

Incremental Loading Patterns

The idea is simple and every hard part is at the edges: what counts as new, what happens on a retry, and what happens when a record arrives after the window it belongs to has already been processed.

Timestamp-Based Incremental

# Python: Incremental extract based on timestamps
import json
from datetime import datetime, timedelta

def incremental_extract(last_sync_time):
    """Extract only records modified since last sync"""
    import psycopg2

    conn = psycopg2.connect("postgresql://...")
    cursor = conn.cursor()

    cursor.execute("""
        SELECT * FROM users
        WHERE updated_at > %s
        ORDER BY updated_at
    """, (last_sync_time,))

    filename = f"incremental_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl"

    with open(filename, 'w') as f:
        for row in cursor:
            record = {
                'id': row[0],
                'email': row[1],
                'updated_at': row[2].isoformat()
            }
            f.write(json.dumps(record) + '\n')

    cursor.close()
    return filename

# Track last sync
with open('last_sync.txt', 'r') as f:
    last_sync = datetime.fromisoformat(f.read().strip())

extract_file = incremental_extract(last_sync)

# Update last sync timestamp
with open('last_sync.txt', 'w') as f:
    f.write(datetime.utcnow().isoformat())

The watermark above has three bugs, and they are the three everyone ships. It records the current time rather than the maximum updated_at actually extracted, so any row committed during the run is skipped forever. It uses a strict greater-than against a sub-second timestamp, which drops rows sharing the boundary value. And it writes the watermark even when the load fails. Derive it from the data, subtract a safety margin for clock skew, and persist it only after the destination acknowledges the batch. Reprocessing a small overlap is cheap; missing rows is not.

Upsert Pattern

Because the window overlaps, the same record arrives more than once. MERGE is what makes the load idempotent: matching on the primary key means a rerun updates instead of inserting, so retrying a failed job is safe by construction. If the source can emit the same key twice in one batch, deduplicate first, because most engines reject a MERGE whose source matches a target row more than once.

-- BigQuery MERGE for upserts
MERGE `project.dataset.users` T
USING (
    SELECT * FROM EXTERNAL_QUERY(
        "projects/project/locations/us/connections/postgres",
        "SELECT * FROM users WHERE updated_at > CURRENT_DATE - 1"
    )
) S
ON T.id = S.id
WHEN MATCHED THEN
    UPDATE SET
        email = S.email,
        name = S.name,
        updated_at = S.updated_at
WHEN NOT MATCHED THEN
    INSERT (id, email, name, created_at, updated_at)
    VALUES (S.id, S.email, S.name, S.created_at, S.updated_at);

Best Practices

Data Quality

  • Validate JSON syntax before loading
  • Implement schema validation
  • Handle nulls and missing fields
  • Deduplicate records
  • Add data quality checks in pipelines

Performance

  • Partition data by date for faster queries
  • Compress JSONL files (gzip, snappy)
  • Use columnar formats (Parquet) for analytics
  • Implement incremental loads
  • Cluster tables on frequently queried columns

Reliability

  • Implement idempotent pipelines
  • Add retry logic with exponential backoff
  • Monitor pipeline failures
  • Maintain data lineage
  • Version your data and schemas

Cost Optimization

  • Use data lifecycle policies
  • Archive old data to cold storage
  • Optimize query patterns
  • Monitor storage costs
  • Use appropriate compression