Skip to content

From Zero to Production: A Practical Guide to LTSeq

Summary: earlier articles covered LTSeq's architecture and algorithms. This one walks through stock consecutive-run analysis, web session modeling, multi-table linked analytics, and funnel analysis with code you can run, plus the pitfalls that cost hours when you hit them cold.


1. Mental model: think in ordered sequences

Traditional SQL and pandas think in sets: unordered collections where each row is independent. LTSeq thinks in sequences: ordered collections where a row's meaning depends on its neighbors.

Set thinking:    "Filter rows where age > 18"           → Each row evaluated independently
Sequence thinking: "Find where price > previous price"  → Row meaning depends on predecessor

This distinction drives every API decision:

  • filter() works on individual rows (set operation)
  • group_ordered() works on consecutive row relationships (sequence operation)
  • shift(1) references the previous row (sequence operation)
  • search_pattern() finds multi-row patterns (sequence operation)

If your problem doesn't involve row order, use pandas or DuckDB. If it does, LTSeq will save you from writing window function gymnastics.


2. Getting started

2.1 Installation

bash
pip install ltseq

2.2 Data loading

LTSeq supports two loading modes:

python
from ltseq import LTSeq

# Eager: load entire file into memory
t = LTSeq.read_csv("events.csv")
t = LTSeq.read_csv("events.csv", has_header=True)     # Default: has_header=True
t = LTSeq.read_parquet("events.parquet")

# Streaming: process batch-by-batch for large files
cursor = LTSeq.scan("large_events.csv")
for batch in cursor:
    process(batch)  # Each batch is a small LTSeq table

cursor = LTSeq.scan_parquet("large_events.parquet")

When to use each:

  • read_csv / read_parquet: Dataset fits in memory. All operations available.
  • scan / scan_parquet: Dataset exceeds memory. Limited to streaming operations.

2.3 Output formats

python
# Display
t.show()                    # Pretty-print ASCII table
t.show(20)                  # Show first 20 rows

# Export
t.write_csv("output.csv")  # Write to CSV
df = t.to_pandas()          # Convert to pandas DataFrame
arrow_table = t.to_arrow()  # Convert to Arrow Table
rows = t.collect()           # List of dicts

2.4 Schema inspection

python
t.schema()     # Print column names and types
t.count()      # Number of rows
t.columns()    # List of column names

3. Use case 1: stock consecutive up-days

The problem: given daily stock prices for multiple stocks, find all runs of 3 or more consecutive up-days and report the start date, end date, and total gain for each run. This is the flagship LTSeq example, and it exercises the core sequence operations.

3.1 Full solution

python
from ltseq import LTSeq

# Sample data
stocks = LTSeq.from_rows([
    {"symbol": "AAPL", "date": "2024-01-02", "close": 150.0},
    {"symbol": "AAPL", "date": "2024-01-03", "close": 152.0},
    {"symbol": "AAPL", "date": "2024-01-04", "close": 155.0},
    {"symbol": "AAPL", "date": "2024-01-05", "close": 153.0},
    {"symbol": "AAPL", "date": "2024-01-08", "close": 156.0},
    {"symbol": "AAPL", "date": "2024-01-09", "close": 158.0},
    {"symbol": "AAPL", "date": "2024-01-10", "close": 161.0},
    {"symbol": "AAPL", "date": "2024-01-11", "close": 163.0},
    {"symbol": "GOOGL", "date": "2024-01-02", "close": 140.0},
    {"symbol": "GOOGL", "date": "2024-01-03", "close": 142.0},
    {"symbol": "GOOGL", "date": "2024-01-04", "close": 141.0},
    {"symbol": "GOOGL", "date": "2024-01-05", "close": 145.0},
])

# Step 1: Sort by symbol, then by date within each symbol
t = stocks.sort("symbol", "date")

# Step 2: Mark each day as up or down, respecting symbol boundaries
t = t.derive(lambda r: {
    "is_up": r.close.if_else(
        r.close > r.close.shift(1),  # Compare with previous day
        1, 0
    )
}, partition_by="symbol")

# Step 3: Group consecutive up/down runs
# A new group starts when is_up changes value OR the symbol changes
groups = t.group_ordered(
    lambda r: (r.symbol != r.symbol.shift(1)) | (r.is_up != r.is_up.shift(1))
)

# Step 4: Filter for runs of 3+ consecutive up-days
long_runs = groups.filter(lambda g: (g.count >= 3) & (g.first.is_up == 1))

# Step 5: Extract run details
result = long_runs.derive(lambda g: {
    "symbol": g.first.symbol,
    "start_date": g.first.date,
    "end_date": g.last.date,
    "days": g.count,
    "gain": g.last.close - g.first.close,
})

result.show()

3.2 Step-by-step breakdown

Step 2, derive with partition_by: the partition_by="symbol" parameter keeps shift(1) from crossing symbol boundaries. Without it, the first GOOGL row would compare against the last AAPL row.

Step 3, group_ordered: the key operation. It creates a new group whenever the boundary condition is true. The predicate (r.symbol != r.symbol.shift(1)) | (r.is_up != r.is_up.shift(1)) starts a new group when either the symbol changes or the up/down status changes.

Step 4, group-level filtering: after group_ordered, each "row" in groups represents a run. g.count is the number of rows in the run, and g.first and g.last access the first and last rows.

Step 5, group-level derive: g.first.close accesses the close price of the first row in each group. This is lazy, so the actual data retrieval happens at materialization.


4. Use case 2: web session analysis

The problem: given a stream of web events ordered by time, segment them into sessions on a 30-minute gap, then compute per-session metrics.

4.1 Sessionization

python
events = LTSeq.read_parquet("web_events.parquet")

# Sort by user, then by event time
events = events.sort("user_id", "event_time")

# Group into sessions: new session when user changes OR 30-min gap
sessions = events.group_ordered(
    lambda r: (r.user_id != r.user_id.shift(1)) |
              (r.event_time - r.event_time.shift(1) > 1800)  # 1800 seconds = 30 minutes
)

This single operation replaces the standard SQL pattern:

sql
-- The SQL equivalent requires 3 window functions and a CTE
WITH lagged AS (
    SELECT *,
        LAG(user_id) OVER (ORDER BY user_id, event_time) AS prev_user,
        LAG(event_time) OVER (ORDER BY user_id, event_time) AS prev_time
    FROM events
),
boundaries AS (
    SELECT *,
        CASE WHEN user_id != prev_user
              OR event_time - prev_time > INTERVAL '30 minutes'
        THEN 1 ELSE 0 END AS is_boundary
    FROM lagged
),
sessions AS (
    SELECT *,
        SUM(is_boundary) OVER (ORDER BY user_id, event_time) AS session_id
    FROM boundaries
)
SELECT * FROM sessions;

4.2 Session metrics

python
# Session duration
session_stats = sessions.derive(lambda g: {
    "user_id": g.first.user_id,
    "session_start": g.first.event_time,
    "session_end": g.last.event_time,
    "event_count": g.count,
    "duration_seconds": g.last.event_time - g.first.event_time,
})

# How many sessions per user?
session_stats.show()

4.3 Counting sessions without materializing

If you only need the count:

python
num_sessions = sessions.first().count()

This triggers the direct_streaming_group_count fast path. No group table is built, just a boundary count. For 100M rows it finishes in ~1 second using ~8 MB of memory.


5. Use case 3: multi-table analytics with linking

The problem: you have three tables, users, orders, and products, and you want to analyze order patterns with enriched user and product data.

5.1 Setting up linked tables

python
users = LTSeq.from_rows([
    {"user_id": 1, "name": "Alice", "tier": "gold"},
    {"user_id": 2, "name": "Bob", "tier": "silver"},
    {"user_id": 3, "name": "Carol", "tier": "gold"},
])

orders = LTSeq.from_rows([
    {"order_id": 101, "user_id": 1, "product_id": "P1", "amount": 50.0, "ts": "2024-01-01"},
    {"order_id": 102, "user_id": 2, "product_id": "P2", "amount": 30.0, "ts": "2024-01-02"},
    {"order_id": 103, "user_id": 1, "product_id": "P1", "amount": 50.0, "ts": "2024-01-03"},
    {"order_id": 104, "user_id": 3, "product_id": "P3", "amount": 80.0, "ts": "2024-01-04"},
    {"order_id": 105, "user_id": 1, "product_id": "P2", "amount": 30.0, "ts": "2024-01-05"},
])

products = LTSeq.from_rows([
    {"product_id": "P1", "name": "Widget", "category": "tools"},
    {"product_id": "P2", "name": "Gadget", "category": "electronics"},
    {"product_id": "P3", "name": "Gizmo", "category": "tools"},
])

# Create lazy links (no join happens yet)
linked_orders = orders.link(users, on="user_id").link(products, on="product_id")

5.2 Transparent materialization

python
# Access linked columns, join happens lazily when needed
gold_orders = linked_orders.filter(lambda r: r.user_id__tier == "gold")
gold_orders.show()

The column name user_id__tier is auto-generated: {foreign_key}__{column_name}. When you access a linked column in a filter or derive, the join is executed transparently.

5.3 Join types

python
# Inner join (default): only matching rows
orders.link(users, on="user_id")

# Left join: keep all orders, NULL for unmatched users
orders.link(users, on="user_id", how="left")

# Composite keys
orders.link(warehouse_stock, on=["product_id", "warehouse_id"])

6. Use case 4: funnel analysis

The problem: given a stream of web events, count how many users complete a three-step funnel of homepage, product page, checkout.

6.1 Pattern matching

python
events = LTSeq.read_parquet("events.parquet")
events = events.sort("user_id", "event_time")

# Count users who complete the funnel
funnel_count = events.search_pattern_count(
    lambda r: r.url.s.starts_with("/"),           # Step 1: homepage
    lambda r: r.url.s.starts_with("/product"),    # Step 2: product page
    lambda r: r.url.s.starts_with("/checkout"),   # Step 3: checkout
    partition_by="user_id"
)

print(f"Funnel completions: {funnel_count}")

6.2 Getting the matching rows

If you need the matching rows themselves rather than a count:

python
matches = events.search_pattern(
    lambda r: r.url.s.starts_with("/"),
    lambda r: r.url.s.starts_with("/product"),
    lambda r: r.url.s.starts_with("/checkout"),
    partition_by="user_id"
)
matches.show()  # Shows the first row of each matching sequence

6.3 Why it's fast

The sparse evaluation algorithm means:

  • Step 1 is evaluated for all rows (vectorized, full scan)
  • Step 2 is only evaluated for rows where step 1 matched (typically ~0.1% of rows)
  • Step 3 is only evaluated for rows where steps 1+2 both matched (even fewer)

For 100M rows, this completes in ~2.4 seconds using ~10 MB of memory, compared to DuckDB's ~3.5 seconds and ~1.2 GB.


7. Use case 5: window functions and rankings

7.1 Rolling calculations

python
prices = LTSeq.from_rows([
    {"date": "2024-01-01", "price": 100.0},
    {"date": "2024-01-02", "price": 102.0},
    {"date": "2024-01-03", "price": 98.0},
    {"date": "2024-01-04", "price": 105.0},
    {"date": "2024-01-05", "price": 103.0},
])

# Sort is required before window functions
prices = prices.sort("date")

# Rolling 3-day average
prices = prices.derive(lambda r: {
    "ma3": r.price.rolling(3).mean(),
    "price_change": r.price.diff(1),           # Difference from previous row
    "prev_price": r.price.shift(1),            # Previous row's value
    "running_total": r.price.cum_sum(),        # Cumulative sum
})

prices.show()

7.2 Ranking

python
scores = LTSeq.from_rows([
    {"dept": "eng", "name": "Alice", "score": 95},
    {"dept": "eng", "name": "Bob", "score": 87},
    {"dept": "eng", "name": "Carol", "score": 95},
    {"dept": "sales", "name": "Dave", "score": 90},
    {"dept": "sales", "name": "Eve", "score": 85},
])

scores = scores.sort("dept", "score")

# Add rankings within each department
scores = scores.derive(lambda r: {
    "row_num": r.score.row_number().over(partition_by="dept", order_by="score", descending=True),
    "rank": r.score.rank().over(partition_by="dept", order_by="score", descending=True),
    "dense_rank": r.score.dense_rank().over(partition_by="dept", order_by="score", descending=True),
    "quartile": r.score.ntile(4).over(partition_by="dept", order_by="score"),
})

scores.show()

8. Use case 6: as-of join for time-series alignment

The problem: match each trade with the most recent quote that occurred before or at the trade time.

python
trades = LTSeq.from_rows([
    {"symbol": "AAPL", "trade_time": 1000, "trade_price": 150.0},
    {"symbol": "AAPL", "trade_time": 1005, "trade_price": 151.0},
    {"symbol": "AAPL", "trade_time": 1010, "trade_price": 149.0},
])

quotes = LTSeq.from_rows([
    {"symbol": "AAPL", "quote_time": 999, "bid": 149.5, "ask": 150.5},
    {"symbol": "AAPL", "quote_time": 1003, "bid": 150.0, "ask": 151.0},
    {"symbol": "AAPL", "quote_time": 1008, "bid": 149.0, "ask": 150.0},
])

# As-of join: for each trade, find the most recent quote
result = trades.asof_join(
    quotes,
    left_on="trade_time",
    right_on="quote_time",
    by="symbol",            # Match within the same symbol
    direction="backward"    # Look backward in time (most recent before)
)

result.show()

Direction options:

  • "backward": Find the most recent row where right_on <= left_on (default)
  • "forward": Find the nearest future row where right_on >= left_on
  • "nearest": Find the closest row in either direction

Under the hood, this uses binary search (partition_point) for O(N log M) complexity.


9. Use case 7: set operations

python
q1_customers = LTSeq.from_rows([
    {"customer_id": 1, "name": "Alice"},
    {"customer_id": 2, "name": "Bob"},
    {"customer_id": 3, "name": "Carol"},
])

q2_customers = LTSeq.from_rows([
    {"customer_id": 2, "name": "Bob"},
    {"customer_id": 3, "name": "Carol"},
    {"customer_id": 4, "name": "Dave"},
])

# Customers in both quarters
retained = q1_customers.intersect(q2_customers)

# New customers in Q2
new_in_q2 = q2_customers.diff(q1_customers)

# All customers across both quarters
all_customers = q1_customers.union(q2_customers)

# Did we retain everyone?
print(q1_customers.is_subset(q2_customers))  # False (Alice was lost)

10. Use case 8: stateful scan

The problem: implement a custom state machine over ordered data. For example, compute a running balance with a floor of 0.

python
transactions = LTSeq.from_rows([
    {"ts": 1, "amount": 100},
    {"ts": 2, "amount": -30},
    {"ts": 3, "amount": -80},   # Would go to -10, but floor at 0
    {"ts": 4, "amount": 50},
])

transactions = transactions.sort("ts")

def balance_machine(state, row):
    new_balance = max(0, state.get("balance", 0) + row["amount"])
    return {"balance": new_balance}, {"balance": new_balance}

result = transactions.stateful_scan(
    balance_machine,
    init_state={"balance": 0},
    output_col="balance"
)

result.show()

stateful_scan processes rows sequentially, maintaining arbitrary Python state. It's the escape hatch for logic that can't be expressed as declarative predicates.


11. The expression DSL: string and temporal operations

11.1 String operations (.s accessor)

python
t = LTSeq.from_rows([
    {"name": "Alice Smith", "email": "[email protected]"},
    {"name": "Bob Jones", "email": "[email protected]"},
])

t = t.derive(lambda r: {
    "first_name": r.name.s.split(" ").s.slice(0, 5),  # First 5 chars
    "domain": r.email.s.contains("example"),            # Boolean
    "upper_name": r.name.s.upper(),
    "name_len": r.name.s.len(),
    "is_com": r.email.s.ends_with(".com"),
    "cleaned": r.name.s.strip(),
    "matched": r.email.s.regex_match(r"@\w+\.com$"),
})

Available string methods: contains, starts_with, ends_with, lower, upper, strip, lstrip, rstrip, len, slice, regex_match, replace, concat, pad_left, pad_right, split.

11.2 Temporal operations (.dt accessor)

python
events = LTSeq.from_rows([
    {"ts": "2024-03-15T10:30:00", "value": 42},
])

events = events.derive(lambda r: {
    "year": r.ts.dt.year(),
    "month": r.ts.dt.month(),
    "day": r.ts.dt.day(),
    "hour": r.ts.dt.hour(),
    "minute": r.ts.dt.minute(),
    "second": r.ts.dt.second(),
})

11.3 Conditional expressions

python
t = t.derive(lambda r: {
    # if_else: condition, true_value, false_value
    "category": r.amount.if_else(r.amount > 100, "high", "low"),

    # Conditional aggregation
    "high_count": r.amount.count_if(r.amount > 100),
    "high_sum": r.amount.sum_if(r.amount > 100),
    "high_avg": r.amount.avg_if(r.amount > 100),

    # NULL handling
    "filled": r.value.fill_null(0),
    "has_value": r.value.is_not_null(),
})

12. Common pitfalls

Pitfall 1: forgetting to sort before window functions

python
t = LTSeq.read_csv("data.csv")

# WRONG: shift requires sorted data
t = t.derive(lambda r: {"prev": r.price.shift(1)})
# → Undefined behavior or error

# RIGHT: sort first
t = t.sort("date")
t = t.derive(lambda r: {"prev": r.price.shift(1)})

Window functions (shift, rolling, diff, cum_sum, row_number, rank, dense_rank) require a sorted table. LTSeq tracks sort state internally, and if you haven't called sort() these operations may raise errors or produce incorrect results.

Pitfall 2: confusing group_ordered with GROUP BY

python
# group_ordered: groups CONSECUTIVE identical values
# If data is [A, A, B, A, A], you get 3 groups: [A,A], [B], [A,A]

# GROUP BY (agg): groups ALL identical values regardless of position
# If data is [A, A, B, A, A], you get 2 groups: [A,A,A,A], [B]

# Use group_ordered for session detection, consecutive run analysis
sessions = t.group_ordered(lambda r: r.user_id != r.user_id.shift(1))

# Use agg for standard aggregation
totals = t.agg(lambda r: {"total": r.amount.sum()}, by="category")

# Use partition for non-consecutive grouping with dict-like access
partitions = t.partition("category")
tools_data = partitions["tools"]

Pitfall 3: cross-partition leakage in shift

python
# WRONG: shift leaks across users
t = t.sort("user_id", "event_time")
t = t.derive(lambda r: {"prev_event": r.event_time.shift(1)})
# → First event of user B gets last event of user A as prev_event

# RIGHT: use partition_by to respect boundaries
t = t.derive(lambda r: {"prev_event": r.event_time.shift(1)}, partition_by="user_id")
# OR use .over() for window functions
t = t.derive(lambda r: {
    "prev_event": r.event_time.shift(1).over(partition_by="user_id", order_by="event_time")
})

Pitfall 4: immutable operations

python
# WRONG: LTSeq operations return new tables, they don't modify in place
t = LTSeq.read_csv("data.csv")
t.filter(lambda r: r.age > 18)  # Return value discarded!
t.show()  # Still shows all rows

# RIGHT: capture the return value
t = LTSeq.read_csv("data.csv")
t = t.filter(lambda r: r.age > 18)  # Reassign
t.show()  # Shows filtered rows

Every LTSeq operation returns a new table. The original is never modified. This is the same pattern as pandas (which also returns new DataFrames), but it's a common source of bugs.

Pitfall 5: schema mismatch in set operations

python
# WRONG: union requires identical schemas
t1 = LTSeq.from_rows([{"a": 1, "b": 2}])
t2 = LTSeq.from_rows([{"a": 1, "c": 3}])
t1.union(t2)  # Error: schemas don't match

# RIGHT: select matching columns first
t2_aligned = t2.select("a").derive(lambda r: {"b": 0})
t1.union(t2_aligned)

Pitfall 6: stateful_scan output column conflict

python
# WRONG: output column already exists
t = LTSeq.from_rows([{"balance": 100, "amount": 50}])
t.stateful_scan(fn, init_state={}, output_col="balance")
# → Error: column "balance" already exists

# RIGHT: use a different output column name
t.stateful_scan(fn, init_state={}, output_col="running_balance")

Pitfall 7: using Python logic in lambdas

python
# WRONG: Python if/else is executed at capture time, not evaluation time
t.filter(lambda r: r.age > 18 if True else r.age > 21)
# → Always evaluates to r.age > 18 (Python evaluates `if True` immediately)

# RIGHT: use if_else expression
t.filter(lambda r: r.flag.if_else(r.flag == 1, r.age > 18, r.age > 21))

LTSeq lambdas are captured, not executed. The lambda body builds an expression tree through operator overloading. Python control flow (if, for, while) executes at capture time and doesn't become part of the expression tree. Use if_else() for conditional logic.


13. Performance tips

Tip 1: use Parquet, not CSV

Parquet enables column projection (read only needed columns), row group skipping (via min/max statistics), and the direct Parquet engine (bypassing DataFusion). CSV reads all columns and all rows.

python
# Slow: reads all 105 columns
t = LTSeq.read_csv("hits.csv")
t.filter(lambda r: r.URL.s.starts_with("/product"))

# Fast: reads only the URL column from Parquet
t = LTSeq.read_parquet("hits.parquet")
t.filter(lambda r: r.URL.s.starts_with("/product"))

Tip 2: sort once, query many

Sort state persists through operations. Sort at the beginning, then chain operations:

python
t = LTSeq.read_parquet("events.parquet")
t = t.sort("user_id", "event_time")  # Sort once

# All subsequent operations inherit sort order
t1 = t.filter(lambda r: r.event_type == "click")
t2 = t.derive(lambda r: {"prev": r.event_time.shift(1)}, partition_by="user_id")
t3 = t.group_ordered(lambda r: r.user_id != r.user_id.shift(1))

Tip 3: use count() instead of collect()

python
# Slow: materializes entire result, then counts
n = len(t.filter(lambda r: r.age > 18).collect())

# Fast: counts without materializing
n = t.filter(lambda r: r.age > 18).count()

Tip 4: chain operations for pipeline efficiency

python
# LTSeq optimizes chained operations
result = (t
    .sort("user_id", "event_time")
    .filter(lambda r: r.event_type == "purchase")
    .derive(lambda r: {"prev_time": r.event_time.shift(1)}, partition_by="user_id")
    .filter(lambda r: r.event_time - r.prev_time < 3600)
    .select("user_id", "event_time", "prev_time")
)

Tip 5: use search_pattern_count for funnel metrics

When you only need the count (not the matching rows), search_pattern_count avoids building the result table:

python
# Slow: builds full result table, then counts
n = events.search_pattern(
    lambda r: r.url.s.starts_with("/"),
    lambda r: r.url.s.starts_with("/product"),
    lambda r: r.url.s.starts_with("/checkout"),
    partition_by="user_id"
).count()

# Fast: counts directly, no result table
n = events.search_pattern_count(
    lambda r: r.url.s.starts_with("/"),
    lambda r: r.url.s.starts_with("/product"),
    lambda r: r.url.s.starts_with("/checkout"),
    partition_by="user_id"
)

14. When to use LTSeq vs. alternatives

ScenarioBest ToolWhy
Ad-hoc SQL queriesDuckDBMature SQL engine, great for exploration
ETL pipelinespandas/PolarsRich ecosystem, broad format support
Distributed processingSpark/TrinoScales beyond single node
Session segmentationLTSeqSingle-pass streaming, 67x less memory
Funnel analysisLTSeqSparse evaluation, 112x less memory
Consecutive-run detectionLTSeqgroup_ordered + sequence DSL
Time-series alignmentLTSeqAs-of join with binary search
General aggregationDuckDBHash-aggregate is 1.7x faster

LTSeq's sweet spot is ordered-sequence operations on 10M to 1B rows on a single machine. If your problem involves row-to-row relationships (previous row, next row, consecutive patterns), LTSeq will be faster and use far less memory than a general-purpose engine.


15. Summary

LTSeq is not a general-purpose database or a pandas replacement. It's a specialized tool for a specific class of problems: operations that depend on row order. For those problems, it offers:

  1. A natural API. group_ordered, shift, and search_pattern express sequential logic directly, without window function gymnastics.
  2. Memory efficiency. 8.5 MB against 571 MB for sessionization on 100M rows.
  3. Competitive throughput. 1.4x to 1.5x faster than DuckDB on ordered-sequence workloads.
  4. Lazy evaluation. Linked tables and nested tables defer expensive operations until needed.
  5. Streaming support. Cursor-based processing for datasets larger than memory.

The five articles in this series have covered the full stack: Rust kernel architecture and expression transpilation, then core algorithms and performance optimization, and now the practical usage patterns in this guide. The code is open source. src/ops/linear_scan.rs holds the bytecode engine, src/ops/parallel_scan.rs the direct Parquet engine, and py-ltseq/ltseq/core.py the Python API surface.