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 predecessorThis 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
pip install ltseq2.2 Data loading
LTSeq supports two loading modes:
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
# 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 dicts2.4 Schema inspection
t.schema() # Print column names and types
t.count() # Number of rows
t.columns() # List of column names3. 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
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
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:
-- 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
# 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:
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
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
# 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
# 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
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:
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 sequence6.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
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
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.
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 whereright_on <= left_on(default)"forward": Find the nearest future row whereright_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
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.
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)
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)
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
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
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
# 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
# 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
# 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 rowsEvery 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
# 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
# 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
# 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.
# 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:
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()
# 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
# 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:
# 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
| Scenario | Best Tool | Why |
|---|---|---|
| Ad-hoc SQL queries | DuckDB | Mature SQL engine, great for exploration |
| ETL pipelines | pandas/Polars | Rich ecosystem, broad format support |
| Distributed processing | Spark/Trino | Scales beyond single node |
| Session segmentation | LTSeq | Single-pass streaming, 67x less memory |
| Funnel analysis | LTSeq | Sparse evaluation, 112x less memory |
| Consecutive-run detection | LTSeq | group_ordered + sequence DSL |
| Time-series alignment | LTSeq | As-of join with binary search |
| General aggregation | DuckDB | Hash-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:
- A natural API.
group_ordered,shift, andsearch_patternexpress sequential logic directly, without window function gymnastics. - Memory efficiency. 8.5 MB against 571 MB for sessionization on 100M rows.
- Competitive throughput. 1.4x to 1.5x faster than DuckDB on ordered-sequence workloads.
- Lazy evaluation. Linked tables and nested tables defer expensive operations until needed.
- 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.