Insights Queries¶
Build typed analytics queries against Mixpanel's Insights engine — the same engine that powers the Mixpanel web UI.
Recommended
Workspace.query() is the primary way to run analytics queries programmatically. It supports capabilities not available through the legacy query methods, including DAU/WAU/MAU, multi-metric comparison, formulas, per-user aggregation, rolling windows, and percentiles.
When to Use query()¶
query() uses the Insights engine via inline bookmark params. The legacy methods (segmentation(), funnel(), retention()) use the older Query API endpoints. Use query() when you need any of the capabilities in the right column:
| Capability | Legacy methods | query() |
|---|---|---|
| Simple event count over time | segmentation() |
ws.query("Login") |
| Unique users | segmentation(type="unique") |
math="unique" |
| DAU / WAU / MAU | Not available | math="dau" |
| Multi-metric comparison | Not available | ["Signup", "Login", "Purchase"] |
| Formulas (conversion rates, ratios) | Not available | formula="(B/A)*100" |
| Per-user aggregation | Not available | per_user="average" |
| Rolling / cumulative analysis | Not available | rolling=7 |
| Percentiles (p25/p75/p90/p99) | Not available | math="p90" |
| Typed filters | Expression strings | Filter.equals("country", "US") |
| Numeric bucketed breakdowns | Not available | GroupBy("revenue", property_type="number") |
| Save query as a report | N/A | result.params → create_bookmark() |
Use the legacy methods when:
- You need to query a saved funnel by ID →
funnel() - You need cohort retention curves →
query_retention()(Retention Queries) - You need to query a saved Flows report →
query_saved_flows()
For ad-hoc funnel conversion analysis with typed step definitions, see Funnel Queries. For ad-hoc flow path analysis with typed step definitions, see Flow Queries.
Getting Started¶
The simplest possible query — total event count per day for the last 30 days:
import mixpanel_headless as mp
ws = mp.Workspace()
result = ws.query("Login")
print(result.df.head())
# date event count
# 0 2025-03-01 Login [Total Events] 142
# 1 2025-03-02 Login [Total Events] 158
Add a time range and aggregation:
# Unique users per week for the last 7 days
result = ws.query("Login", math="unique", last=7, unit="week")
# Last 90 days of DAU
result = ws.query("Login", math="dau", last=90)
# Specific date range
result = ws.query(
"Purchase",
from_date="2025-01-01",
to_date="2025-03-31",
unit="month",
)
Aggregation¶
Counting¶
| Math type | What it counts |
|---|---|
"total" (default) |
Total event occurrences |
"unique" |
Unique users per period |
"dau" |
Daily active users |
"wau" |
Weekly active users |
"mau" |
Monthly active users |
"cumulative_unique" |
Running count of distinct users over time |
"sessions" |
Session count (not events or users) |
# DAU over the last 90 days
result = ws.query("Login", math="dau", last=90)
# Monthly active users
result = ws.query("Login", math="mau", last=6, unit="month")
# Cumulative unique users over time
result = ws.query("Login", math="cumulative_unique", last=90)
# Count sessions instead of events
result = ws.query("Login", math="sessions", last=30)
# Distinct values of a property
result = ws.query("Purchase", math="unique_values", math_property="product_id")
Property Aggregation¶
Aggregate a numeric property across events. Requires math_property:
| Math type | Aggregation |
|---|---|
"total" + math_property |
Sum of a numeric property |
"average" |
Mean value |
"median" |
Median value |
"min" / "max" |
Extremes |
"p25" / "p75" / "p90" / "p99" |
Percentiles |
"percentile" + percentile_value |
Custom percentile (e.g. p95) |
"histogram" |
Distribution of property values |
"unique_values" |
Count of distinct values of a property |
"most_frequent" |
Most commonly occurring property value |
"first_value" |
First observed value per user |
"multi_attribution" |
Multi-touch attribution across a property |
"numeric_summary" |
Summary stats (count, mean, variance) |
# Average purchase amount per day
result = ws.query(
"Purchase",
math="average",
math_property="amount",
from_date="2025-01-01",
to_date="2025-01-31",
)
# P90 response time
result = ws.query("API Call", math="p90", math_property="duration_ms")
# Custom percentile (p95) — use math="percentile" with percentile_value
result = ws.query(
"API Call",
math="percentile",
math_property="duration_ms",
percentile_value=95,
)
# Histogram — distribution of purchase amounts
result = ws.query("Purchase", math="histogram", math_property="amount")
Per-User Aggregation¶
Aggregate per user first, then across all users — like a SQL subquery. For example, "what's the average number of purchases per user per week?"
# Average purchases per user per week
result = ws.query(
"Purchase",
math="total",
per_user="average",
unit="week",
)
Valid per_user values: "unique_values", "total", "average", "min", "max".
Note
per_user is incompatible with dau, wau, mau, and unique math types.
Segment Method¶
Control how events are counted per user with Metric.segment_method:
"all"(default) — count every qualifying event"first"— count only the first qualifying event per user
from mixpanel_headless import Metric
# Only count each user's first purchase
result = ws.query(Metric("Purchase", segment_method="first"), last=30)
The Metric Class¶
When different events need different aggregation settings, use Metric objects instead of plain strings:
from mixpanel_headless import Metric
# Different math per event
result = ws.query([
Metric("Signup", math="unique"),
Metric("Purchase", math="total", property="revenue"),
])
Metric also supports percentile_value for custom percentiles:
# Per-metric custom percentile
result = ws.query(
Metric("API Call", math="percentile", property="duration_ms", percentile_value=95),
)
Plain strings inherit the top-level math, math_property, and per_user defaults. Metric objects override them per-event:
# These are equivalent:
ws.query("Login", math="unique")
ws.query(Metric("Login", math="unique"))
# Top-level defaults apply to all string events:
ws.query(["Signup", "Login"], math="unique")
# Both events use math="unique"
# Metric overrides per event:
ws.query([
Metric("Signup", math="unique"), # unique users
Metric("Purchase", math="total"), # total events
])
Metrics Over More Than One Event¶
Pass a list of events to count them as one series. With math="unique", a user who did any of the events counts once. With math="total", the events of all of them add up:
from mixpanel_headless import Filter, Metric
# Users who signed in by any method, counted once
result = ws.query(Metric(["Login", "SSO Login"], math="unique"))
# Filters on the metric apply to every event
result = ws.query(
Metric(
["Login", "SSO Login"],
math="unique",
filters=[Filter.equals("platform", "iOS")],
)
)
The series is named after the events ("Login or SSO Login"). To choose the name, or to give each event its own filters, use SimpleBehavior:
from mixpanel_headless import Filter, FunnelStep, Metric, SimpleBehavior
signed_in = SimpleBehavior(
["Login", FunnelStep("SSO Login", filters=[Filter.equals("provider", "okta")])],
name="Signed in",
)
result = ws.query(Metric(signed_in, math="unique"))
Filters go on each event, as FunnelStep items with their own filters. The query server ignores filters on the behavior as a whole, so SimpleBehavior has none, and Metric(filters=...) cannot be combined with a SimpleBehavior (MT3_FILTERS_WITH_BEHAVIOR).
A plain list of events takes event names and CustomEventRef items only. Any other item, for example a FunnelStep, raises MT5_INVALID_EVENT_TYPE. In a list, Metric(filters=...) applies to every event, so a step's own filters have no place there. Put the steps in a SimpleBehavior instead.
A saved simple behavior works as the event too: Metric(BehaviorRef(4410, "simple"), math="unique"). The saved behavior sets the events, so Metric(filters=...) cannot be combined with it either.
A funnel step and a retention event take one event each. query_funnel() and query_retention() refuse a list of events there, and the error names custom events as the fix.
Custom Events by ID¶
A custom event is a saved union of events, with optional filters, under one name. Query it by ID with CustomEventRef:
from mixpanel_headless import CustomEventRef, Metric
result = ws.query(Metric(CustomEventRef(42), math="unique"))
# A custom event can also be one of the events of a metric
result = ws.query(Metric([CustomEventRef(42), "Purchase"], math="unique"))
Where only an event name is accepted, such as a query_funnel() step or a query_retention() event, use the name "$custom_event:<id>":
The ID is CustomEvent.id from create_custom_event(), or the custom_event_id of an entry of list_custom_events().
The display name of a custom event returns zero rows
ws.query("My Custom Event") sends the display name as an event name. No event has that name, so the query returns zero rows, with no error. Use CustomEventRef(id) or "$custom_event:<id>".
Funnel and Retention Metrics¶
FunnelMetric and RetentionMetric put a funnel or a retention measurement in an Insights query, next to other metrics or inside a formula. FunnelBehavior and RetentionBehavior take the parameter names and the defaults of query_funnel() and query_retention(), so the same arguments give the same numbers:
from mixpanel_headless import (
FunnelBehavior,
FunnelMetric,
Metric,
RetentionBehavior,
RetentionMetric,
)
checkout = FunnelBehavior(["Checkout", "Purchase"], conversion_window=7)
returning = RetentionBehavior("Signup", "Login", retention_unit="day")
result = ws.query([
Metric("Checkout", math="unique"),
FunnelMetric(checkout, label="Checkout conversion"),
RetentionMetric(returning, bucket_index=7, label="Day 7 retention"),
])
- A funnel metric defaults to the unique conversion rate of the whole funnel.
step_indexmeasures one step. - A property math (
average,median,min,max, the percentiles,histogram) needsproperty, asquery_funnel(math_property=...)does. The query server also refuses a property math without a property. - A retention metric defaults to the retention rate.
bucket_indexpicks the bucket that a line chart trends (0 is the first bucket). labelnames the series. Without it, the server names a funnel after its first and last steps.- The behavior can be a saved one:
FunnelMetric(BehaviorRef(3120, "funnel"))orRetentionMetric(BehaviorRef(4410, "retention")). The saved behavior owns the steps and settings. A reference of another type raisesBH5_BEHAVIOR_REF_TYPE.
Filters¶
Global Filters¶
Apply filters across all metrics with where=. Construct filters using Filter class methods:
from mixpanel_headless import Filter
# Single filter
result = ws.query(
"Purchase",
where=Filter.equals("country", "US"),
)
# Multiple filters (combined with AND)
result = ws.query(
"Purchase",
where=[
Filter.equals("country", "US"),
Filter.greater_than("amount", 50),
],
)
Available Filter Methods¶
String filters:
Filter.equals("browser", "Chrome") # equals value
Filter.equals("browser", ["Chrome", "Firefox"]) # equals any in list
Filter.not_equals("browser", "Safari") # does not equal
Filter.contains("email", "@company.com") # contains substring
Filter.not_contains("url", "staging") # does not contain
Filter.starts_with("email", "admin") # prefix match
Filter.ends_with("email", "@company.com") # suffix match
Numeric filters:
Filter.greater_than("amount", 100) # > 100
Filter.less_than("age", 65) # < 65
Filter.between("amount", 10, 100) # 10 <= x <= 100
Filter.not_between("age", 18, 65) # outside a range
Filter.at_least("score", 80) # >= 80
Filter.at_most("errors", 5) # <= 5
Existence filters:
Boolean filters:
List-of-object filters¶
When a property's value is a list of objects (e.g. cart is a list of {Brand, Category, Price} items), use Filter.list_contains to filter on a subproperty. Discover valid subproperty names and types via Workspace.subproperties() first.
Keyword shorthand for the common equality case — each key=value becomes an inner equality filter. All inner conditions must match the same item:
# Cart contains a nike-branded hat
result = ws.query(
"Cart Viewed",
where=Filter.list_contains("cart", Brand="nike", Category="hats"),
)
Explicit Filter instances for any non-equality operator:
# Cart contains an item costing more than $50
result = ws.query(
"Cart Viewed",
where=Filter.list_contains("cart", Filter.greater_than("Price", 50)),
)
Quantifier — "any" (default) requires at least one item to satisfy all inner conditions; "all" requires every item to:
# Every cart item costs more than $50
where = Filter.list_contains(
"cart",
Filter.greater_than("Price", 50),
quantifier="all",
)
Resource type — Filter.list_contains accepts resource_type="people" for list-of-object people properties (e.g. addresses). When using kwarg shorthand, the inner equality filters inherit the outer resource_type. When passing positional Filter instances, each carries its own resource_type from its own factory call — pass resource_type= explicitly on each inner factory if you want them to match the outer.
# Filter people by a list-of-object property
where = Filter.list_contains("addresses", resource_type="people", City="Brooklyn")
Cannot be nested (a list_contains cannot appear inside another list_contains). Mixing the kwarg and positional shapes in one call raises ValueError.
Per-Metric Filters¶
Apply filters to individual metrics using Metric.filters:
from mixpanel_headless import Metric, Filter
# Different filters on each event
result = ws.query([
Metric("Purchase", math="unique"),
Metric(
"Purchase",
math="unique",
filters=[Filter.equals("plan", "premium")],
),
])
By default, multiple per-metric filters combine with AND logic. Use filters_combinator="any" for OR logic:
result = ws.query(Metric(
"Purchase",
math="unique",
filters=[
Filter.equals("country", "US"),
Filter.equals("country", "CA"),
],
filters_combinator="any", # match US OR CA
))
Date Filters¶
Filter by datetime properties using purpose-built factory methods:
from mixpanel_headless import Filter
# Absolute date filters
Filter.on("created", "2025-01-15") # exact date match
Filter.not_on("created", "2025-01-15") # not on date
Filter.before("created", "2025-01-01") # before a date
Filter.since("created", "2025-01-01") # on or after a date
Filter.date_between("created", "2025-01-01", "2025-06-30") # date range
# Relative date filters — "in the last N units"
Filter.in_the_last("created", 30, "day") # last 30 days
Filter.in_the_last("last_seen", 2, "week") # last 2 weeks
Filter.not_in_the_last("created", 90, "day") # NOT in last 90 days
Filter.date_not_between("created", "2025-01-01", "2025-06-30") # dates outside a range
Filter.in_the_next("renewal_date", 30, "day") # relative future date
The relative date methods accept a FilterDateUnit: "hour", "day", "week", or "month".
from mixpanel_headless import FilterDateUnit # Literal["hour", "day", "week", "month"]
# Example: recent signups with purchases
result = ws.query(
"Purchase",
where=Filter.in_the_last("signup_date", 7, "day"),
last=30,
)
Breakdowns¶
String Breakdowns¶
Break down results by property values with group_by:
# Simple string breakdown
result = ws.query("Login", group_by="platform", last=14)
# Multiple breakdowns
result = ws.query("Purchase", group_by=["country", "platform"])
The GroupBy Class¶
For numeric bucketing, boolean breakdowns, or explicit type annotations, use GroupBy:
from mixpanel_headless import GroupBy
# Numeric breakdown with buckets
result = ws.query(
"Purchase",
group_by=GroupBy(
"revenue",
property_type="number",
bucket_size=50,
bucket_min=0,
bucket_max=500,
),
)
# Boolean breakdown
result = ws.query(
"Login",
group_by=GroupBy("is_premium", property_type="boolean"),
)
# Mixed: string shorthand + GroupBy
result = ws.query(
"Purchase",
group_by=[
"country",
GroupBy("amount", property_type="number", bucket_size=25),
],
)
List-of-object breakdowns¶
Mirror Filter.list_contains for breakdowns: when a property is a list of objects, break down by one of its subproperties via GroupBy.list_item. Discover valid subproperty names and types via Workspace.subproperties().
from mixpanel_headless import GroupBy
# Break down Cart Viewed events by cart.Brand
result = ws.query("Cart Viewed", group_by=GroupBy.list_item("cart", "Brand"))
# Break down by a numeric subproperty (sub_type controls aggregation)
result = ws.query(
"Cart Viewed",
group_by=GroupBy.list_item("cart", "Price", sub_type="number"),
)
# Mix list-item with regular breakdowns
result = ws.query(
"Cart Viewed",
group_by=["country", GroupBy.list_item("cart", "Brand")],
)
sub_type accepts the four scalar values from CustomPropertyType ("string", "number", "boolean", "datetime"). Bucketing (bucket_size/bucket_min/bucket_max) is incompatible with list-item breakdowns.
Asymmetric with Filter.list_contains
GroupBy.list_item is events-only — there is no resource_type parameter, because Mixpanel's UI does not support list-of-object breakdowns for people properties. Filter.list_contains accepts resource_type="people" because the wire format permits list-object filters on people properties (just not breakdowns).
Formulas¶
Compute derived metrics from multiple events. Letters A-Z reference events by their position in the list, or the formula's own operands when it has them.
Top-Level formula Parameter¶
from mixpanel_headless import Metric
# Conversion rate: purchases / signups * 100
result = ws.query(
[Metric("Signup", math="unique"), Metric("Purchase", math="unique")],
formula="(B / A) * 100",
formula_label="Conversion Rate",
unit="week",
)
When formula is set, the underlying metrics are automatically hidden — only the formula result appears in the output.
Formula Class in Events List¶
For inline formula definitions, pass Formula objects alongside events:
from mixpanel_headless import Metric, Formula
result = ws.query([
Metric("Signup", math="unique"),
Metric("Purchase", math="unique"),
Formula("(B / A) * 100", label="Conversion Rate"),
])
Both approaches produce identical results. Use whichever reads more naturally.
Formulas With Their Own Operands¶
Formula(expression, metrics=[...]) holds its own operands. The letters name the operands (A is the first, B the second, Z the 26th, then BA, BB), not the other metrics of the query. So the formula can be the whole query, and it hides no other metric:
from mixpanel_headless import Formula, FunnelBehavior, FunnelMetric, Metric
result = ws.query(
Formula(
"A / B",
label="Purchases per checkout",
metrics=[
Metric("Purchase", math="total"),
Metric("Checkout", math="total"),
],
),
last=30,
)
# Operands can be funnel and retention metrics too
result = ws.query(
Formula(
"A * 100",
label="Checkout conversion %",
metrics=[FunnelMetric(FunnelBehavior(["Checkout", "Purchase"]))],
)
)
An operand is a Metric, CohortMetric, FunnelMetric, RetentionMetric, or a MetricRef to a saved behavior metric, never a formula or a warehouse metric (the server accepts only behavior metrics as operands; run a warehouse metric alone by reference). A MetricRef operand is written as {"type": "metric", "id": ...} and takes no override. A bare MetricRef(id) keeps the default kind, so the library cannot detect a warehouse metric behind it; SavedMetric.to_ref() carries the kind. A saved formula stores this same form. The operands and the expression are checked when the Formula is built:
| Code | Rule |
|---|---|
FM2_UNKNOWN_LETTER |
Each letter names an operand. |
FM3_NESTED_FORMULA |
No operand is a formula or a reference to a saved formula. |
MR2_OPERAND_OVERRIDE |
A MetricRef operand sets no override (the server ignores overrides on an operand). |
FM7_WAREHOUSE_OPERAND |
No operand is a warehouse metric (MetricRef(id, type="warehouse")). |
FM4_SYNTAX |
The expression uses + - * / ^, unary minus, parentheses, numbers, and letters. Only a number, a letter, or a parenthesized expression can follow ^: write A ^ (-B), not A ^ -B. |
FM5_UPPER_E |
A number uses a lowercase exponent (1e5, not 1E5). |
V16_FORMULA_SYNTAX |
The expression uses at least one letter. |
A formula without operands keeps its checks: V16_FORMULA_SYNTAX and V19_FORMULA_BOUNDS first, then FM4_SYNTAX when both pass.
Multi-Metric Comparison (No Formula)¶
Compare multiple events side by side without a formula:
# Three events on the same chart
result = ws.query(
["Signup", "Login", "Purchase"],
math="unique",
last=30,
)
Saved Metrics by Reference¶
A saved metric is a project entity with a numeric id: a behavior metric, a saved formula, or a warehouse metric. MetricRef puts a saved metric into a query by id. Use it anywhere a Metric goes:
import mixpanel_headless as mp
# A saved metric as it is saved
result = ws.query(mp.MetricRef(88999), last=30)
# Saved and inline metrics side by side, with a formula over them
result = ws.query(
[mp.MetricRef(88999), mp.Metric("Signup", math="unique")],
formula="A / B",
)
The params keep the reference as {"type": "metric", "id": 88999}. The server replaces it with the saved definition when the query runs. So a report or a report link built from these params follows later edits to the saved metric, the same way a report built in the web app does.
The default type is "metric". The server corrects the kind of a top-level reference, so MetricRef(id) also works for a saved formula or a warehouse metric. Set type="formula" or type="warehouse" when you know the kind.
A SavedMetric from ws.list_metrics() or ws.get_metric() works the same way: pass it as is, or call saved.to_ref(...) with overrides. It takes its kind from the saved metric. See Saved Metrics and Behaviors.
Overrides¶
The typed fields of MetricRef change the saved definition for one query only:
| Field | Wire path |
|---|---|
label |
name (the series name) |
math |
measurement.math ("percentile" becomes "custom_percentile") |
property |
measurement.property |
per_user |
measurement.perUserAggregation |
percentile_value |
measurement.percentile |
segment_method |
measurement.segmentMethod |
funnel_order |
behavior.funnelOrder |
step_index |
measurement.stepIndex |
bucket_index |
measurement.retentionBucketIndex |
hidden |
isHidden |
The library writes them into overrides on the clause, and the server deep-merges overrides into the expanded definition:
result = ws.query(
mp.MetricRef(88999, segment_method="first", label="First purchase"),
group_by="$os",
)
# params["sections"]["show"][0] ==
# {"type": "metric", "id": 88999,
# "overrides": {"name": "First purchase",
# "measurement": {"segmentMethod": "first"}}}
A dict value merges into the saved dict at the same path. So a property override keeps the keys of a saved custom property; to replace a saved custom property with a plain property, send the metric inline.
overrides= takes a raw dict for any other path. It merges after the typed fields, so a raw value wins. A formula or warehouse reference takes label, hidden, and raw overrides only; the other fields change a behavior metric (MR6_OVERRIDE_NOT_APPLICABLE).
Filters are not an override¶
The server merges lists in overrides item by item. A filter list in overrides would change the saved filters by position instead of adding to them. So MetricRef refuses any filters key in overrides (MR1_FILTER_OVERRIDE). To filter a saved metric:
- Use report-level
where=. It applies to every metric in the query. - Or send the metric inline as a
Metricwith its ownfilters.
Warehouse metrics¶
The server runs a warehouse metric by saved id only, and it gives the warehouse series no breakdown and no filter. A query that pairs a warehouse reference with group_by or where still runs, and the library logs a V28_WAREHOUSE_BREAKDOWN warning.
Saved behaviors in funnels and retention¶
A saved behavior is a reusable "what users did": a funnel, a retention pair, or a simple behavior. BehaviorRef(id, type), or a SavedBehavior from ws.list_behaviors() or ws.get_behavior(), passes one to the funnel and retention engines in place of the steps or the events:
# The saved funnel sets the steps, the window, the order, and the exclusions
result = ws.query_funnel(mp.BehaviorRef(3120, "funnel"), last=90)
# The saved retention behavior sets both events and the retention settings
result = ws.query_retention(mp.BehaviorRef(4410, "retention"))
The behavior block becomes {"type": "funnel", "id": 3120}, and the server expands it at query time. The saved behavior owns its settings, so the engine arguments that change them must keep their defaults:
query_funnel:conversion_window,conversion_window_unit,order,exclusions,holding_constant, andreentry_mode(F14_BEHAVIOR_REF_SETTINGS).query_retention:return_event,retention_unit,alignment,bucket_sizes, andunbounded_mode(R15_BEHAVIOR_REF_SETTINGS).
The server expands a behavior id without checking its type, so the engines check it: a funnel query needs a "funnel" behavior (F13_BEHAVIOR_REF_TYPE) and a retention query needs a "retention" behavior (R14_BEHAVIOR_REF_TYPE).
Result labels¶
A reference changes the series label. The series is named after the saved metric or behavior, with no math suffix such as [Total Events], and result.headers is ["$event"] for a metric reference. result.df puts the saved name in the event column.
Time Ranges¶
Relative (Default)¶
By default, query() returns the last 30 days. Customize with last and unit:
# Last 7 days (daily granularity)
result = ws.query("Login", last=7)
# Last 4 weeks (weekly granularity)
result = ws.query("Login", last=4, unit="week")
# Last 6 months
result = ws.query("Login", last=6, unit="month")
The unit controls both what "last N" means and how data is bucketed on the time axis.
Absolute¶
Specify explicit start and end dates:
# Q1 2025
result = ws.query(
"Purchase",
from_date="2025-01-01",
to_date="2025-03-31",
unit="week",
)
# From a date to today
result = ws.query("Login", from_date="2025-01-01")
Dates must be in YYYY-MM-DD format.
Hourly Granularity¶
Use unit="hour" for intraday analysis:
Analysis Modes¶
Rolling Windows¶
Smooth noisy data with a rolling average:
# 7-day rolling average of signups by country
result = ws.query(
"Signup",
math="unique",
group_by="country",
rolling=7,
last=60,
)
Cumulative¶
Show running totals over time:
Note
rolling and cumulative are mutually exclusive.
Result Modes¶
The mode parameter controls result aggregation semantics:
| Mode | Semantics | Use case |
|---|---|---|
"timeseries" (default) |
Per-period values | Trends over time |
"total" |
Single aggregate across the date range | KPI numbers |
"table" |
Tabular detail | Detailed breakdowns |
# Single KPI number: total unique purchasers this month
result = ws.query(
"Purchase",
math="unique",
from_date="2025-03-01",
to_date="2025-03-31",
mode="total",
)
total = result.df["count"].iloc[0]
Mode affects aggregation
mode="total" with math="unique" deduplicates users across the entire date range. mode="timeseries" with math="unique" counts unique users per period (not additive across periods). This is not just a display difference — it changes the numbers.
Period-over-Period Comparison¶
Compare the current time range against a previous period using TimeComparison:
from mixpanel_headless import TimeComparison
# Compare against previous week
result = ws.query("Login", time_comparison=TimeComparison.relative("week"), last=7)
# Compare against window starting on a fixed date
result = ws.query(
"Purchase",
time_comparison=TimeComparison.absolute_start("2025-01-01"),
from_date="2026-01-01",
to_date="2026-01-31",
)
# Compare against window ending on a fixed date
result = ws.query(
"Purchase",
time_comparison=TimeComparison.absolute_end("2025-12-31"),
from_date="2026-01-01",
to_date="2026-01-31",
)
Three factory methods:
| Method | What it compares against |
|---|---|
TimeComparison.relative(unit) |
Previous period offset by unit (day, week, month, quarter, year) |
TimeComparison.absolute_start(date) |
Window starting on a fixed date, same duration |
TimeComparison.absolute_end(date) |
Window ending on a fixed date, same duration |
TimeComparison also works with query_funnel() and query_retention().
Frequency Analysis¶
Frequency Breakdown¶
Break down results by how often users performed an event using FrequencyBreakdown:
from mixpanel_headless import FrequencyBreakdown
# How are logins distributed by purchase frequency?
result = ws.query(
"Login",
group_by=FrequencyBreakdown(
event="Purchase",
bucket_size=1,
bucket_min=0,
bucket_max=10,
),
last=30,
)
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
event |
str |
required | Event to count frequency for |
bucket_size |
int |
1 |
Width of each frequency bucket |
bucket_min |
int |
0 |
Minimum frequency value |
bucket_max |
int |
10 |
Maximum frequency value |
label |
str \| None |
None |
Display label |
Frequency Filter¶
Filter to users who performed an event a certain number of times using FrequencyFilter:
from mixpanel_headless import FrequencyFilter
# Logins in March by users who purchased at least 3 times that month
result = ws.query(
"Login",
where=FrequencyFilter(
event="Purchase",
value=3,
operator="is at least",
),
from_date="2026-03-01",
to_date="2026-03-31",
unit="month",
)
Operators: "is at least", "is at most", "is greater than", "is less than", "is equal to".
The count is evaluated per time bucket, not over the date range
The query engine evaluates a FrequencyFilter threshold inside each
time bucket of the query (unit). With the default unit="day",
FrequencyFilter("Purchase", value=3) keeps only users who purchased three
or more times on the same day; a user who purchased 30 times in a month
but never three times on one day is excluded from every daily bucket. An
empty series / zero-row df is the expected result when no user reaches
the threshold inside one bucket, not a sign that the filter failed.
Choose the unit that matches the period you mean: "day" for "N times
in a day", "month" for "N times in a month". unit="month" over a
multi-month range still yields one threshold per month; "N times over the
whole period" needs a date range that fits inside a single bucket. last=
is always a number of days regardless of unit, so pin a month with
from_date / to_date.
Measured on a seeded 10,000-user dataset (event enter dungeon,
2026-03-01 to 2026-03-31), with every library number matching a DuckDB
ground-truth count over the raw events. The default math="total" counts
events; math="unique" counts users.
| Query | math |
Library result | Ground truth |
|---|---|---|---|
unfiltered, unit="day", summed over days |
total (default) |
30,541 | 30,541 events from 8,281 users |
FrequencyFilter(value=2), unit="day", summed over days |
total |
3,893 | 3,893 events by users with 2+ on the same day (1,894 user-days) |
FrequencyFilter(value=3), unit="day" |
total |
305 | 305 events, same-day 3+ |
FrequencyFilter(value=4), unit="day" |
total |
20 | 20 events, same-day 4+ |
FrequencyFilter(value=5), unit="day" |
total |
empty series | same-day 5+: no user-days. Whole-month 5+: 2,571 users, 16,363 events (not what a daily query measures) |
FrequencyFilter(value=2), unit="month" |
total |
29,188 | 29,188 events by the 6,928 users with 2+ in the month |
FrequencyFilter(value=2), unit="month" |
unique |
6,928 | 6,928 users with 2+ in the month |
date_range_value / date_range_unit are unverified for inline filters
These two parameters render as a behavior.dateRange lookback on the
wire. In a 2026-09-11 probe against the analytics query API they had no
observable effect on inline insights filters: a 31-day lookback returned
the same numbers as no lookback. Their behavior for inline filters has not
been verified against Mixpanel's internal fixtures. Do not rely on them to
widen the counting window; use unit instead.
On 0.2.1? Upgrade
mixpanel-headless 0.2.1 emitted a frequency-filter clause the query
engine could not evaluate, so every FrequencyFilter query failed with
ServerError: Server error: An unknown error occurred. There is no
client-side workaround; upgrade to >=0.2.2.
Data Groups¶
Scope a query to a specific data group for group-level analytics:
data_group_id is available on all query engines: query(), query_funnel(), query_retention(), and query_flow().
Working with Results¶
QueryResult¶
query() returns a QueryResult with:
result = ws.query("Login", math="unique", last=7)
# DataFrame (lazy, cached)
result.df # pandas DataFrame
# Raw series data
result.series # {"Login [Unique Users]": {"2025-03-01...": 142, ...}}
# Time range
result.from_date # "2025-03-25T00:00:00-07:00"
result.to_date # "2025-03-31T23:59:59.999000-07:00"
# Metadata
result.computed_at # "2025-03-31T12:00:00.000000+00:00"
result.headers # ["$metric"]
result.meta # {"min_sampling_factor": 1.0, ...}
# Generated bookmark params (for debugging or persistence)
result.params # dict — the full bookmark JSON sent to API
DataFrame Structure¶
For timeseries mode, the DataFrame has columns date, event, count:
result = ws.query(["Signup", "Login"], math="unique")
print(result.df.head())
# date event count
# 0 2025-03-01 Signup [Unique Users] 85
# 1 2025-03-01 Login [Unique Users] 312
# 2 2025-03-02 Signup [Unique Users] 92
For total mode, the DataFrame has columns event, count (no date).
Persisting as a Saved Report¶
The generated bookmark params can be saved as a Mixpanel report:
from mixpanel_headless import CreateBookmarkParams
# Run query
result = ws.query("Login", math="dau", group_by="platform", last=90)
# Save as a report using the generated params
ws.create_bookmark(CreateBookmarkParams(
name="DAU by Platform (90d)",
bookmark_type="insights",
params=result.params,
))
Debugging¶
Inspect result.params to see the exact bookmark JSON sent to the API. This is useful for:
- Understanding what was actually queried
- Comparing with Mixpanel web UI bookmark params
- Diagnosing unexpected results
import json
result = ws.query("Login", math="unique", group_by="platform")
print(json.dumps(result.params, indent=2))
Validation¶
query() validates all parameter combinations before making an API call and raises ValueError with descriptive messages:
| Rule | Error message |
|---|---|
| Property math without property | math='average' requires math_property to be set |
| Property set with counting math | math_property is only valid with property-based math types |
| Per-user with DAU/WAU/MAU | per_user is incompatible with math='dau' |
| Formula with < 2 events | formula requires at least 2 events |
| Rolling + cumulative | rolling and cumulative are mutually exclusive |
to_date without from_date |
to_date requires from_date |
| Invalid date format | from_date must be YYYY-MM-DD format |
| Invalid bucket config | bucket_min/bucket_max require bucket_size |
Complete Examples¶
Revenue Dashboard Metrics¶
import mixpanel_headless as mp
from mixpanel_headless import Metric, Filter, GroupBy
ws = mp.Workspace()
# Total revenue by country this quarter
revenue = ws.query(
"Purchase",
math="total",
math_property="amount",
group_by="country",
from_date="2025-01-01",
to_date="2025-03-31",
unit="month",
)
# Revenue distribution by bucket
distribution = ws.query(
"Purchase",
group_by=GroupBy(
"amount",
property_type="number",
bucket_size=25,
bucket_min=0,
bucket_max=500,
),
last=30,
)
# Conversion rate with per-metric filters
conversion = ws.query(
[
Metric("Purchase", math="unique"),
Metric(
"Purchase",
math="unique",
filters=[Filter.equals("plan", "premium")],
),
],
formula="(B / A) * 100",
formula_label="Premium %",
group_by="platform",
unit="week",
)
User Engagement Analysis¶
# 7-day rolling average of DAU by platform
engagement = ws.query(
"Login",
math="dau",
group_by="platform",
rolling=7,
last=90,
)
# Average sessions per user per week
sessions = ws.query(
"Session Start",
math="total",
per_user="average",
unit="week",
last=12,
)
# WAU trend for premium users
wau = ws.query(
"Login",
math="wau",
where=Filter.is_true("is_premium"),
last=6,
unit="month",
)
Generating Params Without Querying¶
Use build_params() to generate bookmark params without making an API call — useful for debugging, inspecting the generated JSON, or saving queries as reports:
# Same arguments as query(), returns dict instead of QueryResult
params = ws.build_params(
"Login",
math="dau",
group_by="platform",
where=Filter.in_the_last("created", 30, "day"),
last=90,
)
import json
print(json.dumps(params, indent=2)) # inspect the generated bookmark JSON
# Save as a report directly from params
ws.create_bookmark(CreateBookmarkParams(
name="DAU by Platform (90d)",
bookmark_type="insights",
params=params,
))
Running Built Params¶
Use run_params() to execute params that build_params() produced (or params you wrote by hand). It returns the same QueryResult as query(), and it is the way to run params the typed builder cannot express, such as a lookup-table join breakdown:
params = ws.build_params("Login", group_by="$city", last=7)
params["sections"]["filter"] = my_custom_filter # edit before it runs
result = ws.run_params(params, limit=50_000)
print(result.df.head())
Both query() and run_params() accept limit= (1 to 50000, default 3000) to raise the segment cap for high-cardinality breakdowns. Check result.meta["is_segmentation_limit_hit"] to see whether the result was still truncated.
What's Next¶
query() is the foundation for a family of typed query methods. Each follows the same pattern — typed Python arguments generating the correct bookmark params:
query_funnel()— Ad-hoc funnel conversion analysis with typed step definitions, exclusions, and conversion windowsquery_retention()— Ad-hoc retention curves with event pairs, custom buckets, and alignment modesquery_flow()— Ad-hoc flow path analysis with step definitions, direction controls, and visualization modes- Cohort-scoped queries — Filter, break down, or track cohort membership across all engines (see below)
Cohort-Scoped Queries¶
Scope any query to a user segment — filter by cohort membership, break down by cohort, or track cohort size as a metric. Use saved cohort IDs or define cohorts inline with CohortDefinition.
Cohort Filters¶
Restrict queries to users in (or not in) a cohort using Filter.in_cohort() and Filter.not_in_cohort():
from mixpanel_headless import Filter, CohortCriteria, CohortDefinition
# Saved cohort
result = ws.query("Purchase", where=Filter.in_cohort(123, "Power Users"))
# Inline cohort — define the segment right where you use it
power_users = CohortDefinition(
CohortCriteria.did_event("Purchase", at_least=3, within_days=30)
)
result = ws.query("Login", where=Filter.in_cohort(power_users, name="Power Users"))
# Exclude a cohort
result = ws.query("Purchase", where=Filter.not_in_cohort(789, "Bots"))
# Combine with property filters
result = ws.query(
"Purchase",
where=[Filter.in_cohort(power_users, name="PU"), Filter.equals("platform", "iOS")],
)
Cohort filters work with all five query methods: query(), query_funnel(), query_retention(), query_flow(), and query_user().
Cohort Breakdowns¶
Segment results by cohort membership using CohortBreakdown in the group_by= parameter:
from mixpanel_headless import CohortBreakdown
# Compare cohort vs. everyone else
result = ws.query(
"Purchase",
group_by=CohortBreakdown(123, "Power Users"),
)
# Result segments: "Power Users" and "Not In Power Users"
# Inline cohort breakdown
result = ws.query(
"Purchase",
group_by=CohortBreakdown(power_users, name="Power Users"),
)
# Only the cohort segment (no "Not In" group)
result = ws.query(
"Purchase",
group_by=CohortBreakdown(123, "PU", include_negated=False),
)
# Mix with property breakdowns
result = ws.query(
"Purchase",
group_by=[CohortBreakdown(123, "Power Users"), "platform"],
)
Cohort breakdowns work with query(), query_funnel(), and query_retention() (not flows).
Cohort Metrics¶
Track cohort size over time as a metric — insights only:
from mixpanel_headless import CohortMetric, Metric
# Track cohort growth
result = ws.query(CohortMetric(123, "Power Users"), last=90, unit="week")
# What % of active users are power users?
result = ws.query(
[Metric("Login", math="unique"), CohortMetric(123, "Power Users")],
formula="(B / A) * 100",
formula_label="Power User %",
)
CohortMetric is insights-only — it cannot be used with query_funnel(), query_retention(), or query_flow().
Engine Compatibility¶
| Capability | query() |
query_funnel() |
query_retention() |
query_flow() |
|---|---|---|---|---|
Cohort Filters (where=) |
✓ | ✓ | ✓ | ✓ |
Cohort Breakdowns (group_by=) |
✓ | ✓ | ✓ | — |
Cohort Metrics (events=) |
✓ | — | — | — |
Custom Properties in Queries¶
Use saved custom properties or define computed properties inline — in breakdowns, filters, and metric measurement. Custom properties work everywhere a plain string property name does.
To create and manage custom properties in Mixpanel, see Data Governance — Custom Properties.
Referencing a Saved Custom Property¶
Use CustomPropertyRef to reference a custom property that already exists in your Mixpanel project by its numeric ID:
from mixpanel_headless import CustomPropertyRef, GroupBy, Filter, Metric
ref = CustomPropertyRef(42)
# Breakdown by saved custom property
result = ws.query("Purchase", group_by=GroupBy(property=ref, property_type="number"))
# Filter by saved custom property
result = ws.query("Purchase", where=Filter.greater_than(property=ref, value=100))
# Aggregate a saved custom property
result = ws.query(Metric("Purchase", math="average", property=ref))
Find custom property IDs with ws.list_custom_properties() or mp custom-properties list.
Inline Custom Properties¶
Use InlineCustomProperty to define a computed property at query time — no need to save it to your project first. Formulas reference raw properties through single-letter variables (A–Z), each mapped to a PropertyInput:
from mixpanel_headless import InlineCustomProperty, PropertyInput
# Full constructor — explicit control over types
revenue = InlineCustomProperty(
formula="A * B",
inputs={
"A": PropertyInput("price", type="number"),
"B": PropertyInput("quantity", type="number"),
},
property_type="number",
)
For the common case of numeric formulas over event properties, use the numeric() convenience constructor:
# Shorthand — auto-creates numeric PropertyInput objects
revenue = InlineCustomProperty.numeric("A * B", A="price", B="quantity")
Both forms produce identical results. Use the full constructor when you need non-numeric types or user-profile properties (resource_type="user").
Custom Property Breakdowns¶
Pass a custom property to GroupBy.property for breakdowns. Numeric bucketing works the same as with regular properties:
from mixpanel_headless import GroupBy, CustomPropertyRef, InlineCustomProperty
# Saved custom property with numeric buckets
result = ws.query(
"Purchase",
group_by=GroupBy(
property=CustomPropertyRef(42),
property_type="number",
bucket_size=50,
),
)
# Inline computed property
result = ws.query(
"Purchase",
group_by=GroupBy(
property=InlineCustomProperty.numeric("A * B", A="price", B="quantity"),
property_type="number",
bucket_size=100,
bucket_min=0,
bucket_max=1000,
),
)
# Mix with regular property breakdowns
result = ws.query(
"Purchase",
group_by=["country", GroupBy(property=CustomPropertyRef(42), property_type="number")],
)
Custom Property Filters¶
All 18 Filter factory methods accept custom properties in the property parameter:
from mixpanel_headless import Filter, CustomPropertyRef, InlineCustomProperty
# Saved custom property
result = ws.query(
"Purchase",
where=Filter.greater_than(property=CustomPropertyRef(42), value=100),
)
# Inline computed property
result = ws.query(
"Purchase",
where=Filter.between(
property=InlineCustomProperty.numeric("A * B", A="price", B="quantity"),
value=[100, 1000],
),
)
# Combine with regular filters
result = ws.query(
"Purchase",
where=[
Filter.equals("country", "US"),
Filter.greater_than(property=CustomPropertyRef(42), value=50),
],
)
Custom Property Measurement¶
Aggregate a custom property as the metric value using Metric(property=...):
from mixpanel_headless import Metric, CustomPropertyRef, InlineCustomProperty
# Average of a saved custom property
result = ws.query(
Metric("Purchase", math="average", property=CustomPropertyRef(42)),
)
# Sum of an inline computed property
result = ws.query(
Metric("Purchase", math="total", property=InlineCustomProperty.numeric("A * B", A="price", B="quantity")),
)
# Per-metric custom properties in multi-metric queries
result = ws.query([
Metric("Purchase", math="total", property=InlineCustomProperty.numeric("A * B", A="price", B="quantity")),
Metric("Purchase", math="unique"),
])
Use Metric(property=...), not math_property=
The top-level math_property parameter only accepts plain string property names. To use a custom property for measurement, wrap the event in a Metric object and set property= on it.
Engine Compatibility¶
| Capability | query() |
query_funnel() |
query_retention() |
query_flow() |
|---|---|---|---|---|
CP Breakdowns (group_by=) |
✓ | ✓ | ✓ | — |
CP Filters (where=) |
✓ | ⚠ | ⚠ | — |
CP Measurement (Metric.property=) |
✓ | — | — | — |
⚠ = Supported, but a known Mixpanel server bug may cause errors when custom property filters are used in funnel and retention global where=. Custom property breakdowns and measurement work reliably in those engines.
query_flow() does not support custom properties in any position (Mixpanel limitation).
Custom Property Validation¶
Custom properties are validated before any API call. Invalid configurations raise BookmarkValidationError:
| Rule | Error code | Error message |
|---|---|---|
| ID must be positive integer | CP1_INVALID_ID |
custom property ID must be a positive integer (got {id}) |
| Formula must be non-empty | CP2_EMPTY_FORMULA |
inline custom property formula must be non-empty |
| At least one input required | CP3_EMPTY_INPUTS |
inline custom property must have at least one input |
| Input keys must be single A–Z | CP4_INVALID_INPUT_KEY |
input keys must be single uppercase letters (A-Z), got {key!r} |
| Formula max 20,000 chars | CP5_FORMULA_TOO_LONG |
formula exceeds maximum length of 20,000 characters (got {len}) |
| Input property name non-empty | CP6_EMPTY_INPUT_NAME |
input {key!r} has an empty property name |
from mixpanel_headless import BookmarkValidationError, CustomPropertyRef
try:
ws.query("Purchase", group_by=GroupBy(property=CustomPropertyRef(0), property_type="number"))
except BookmarkValidationError as e:
for error in e.errors:
print(f"[{error.code}] {error.path}: {error.message}")
# [CP1_INVALID_ID] group_by[0].property: custom property ID must be a positive integer (got 0)
Next Steps¶
- Funnel Queries — Typed funnel conversion analysis
- Retention Queries — Typed retention analysis with event pairs and custom buckets
- Flow Queries — Typed flow path analysis with steps, directions, and graph output
- Live Analytics — Legacy query methods (segmentation, funnels, retention)
- Data Discovery — Explore events and properties before querying
- Data Governance — Custom Properties — Create and manage custom properties
- API Reference — Workspace — Full method signatures
- API Reference — Types — Metric, Filter, GroupBy, CustomPropertyRef, InlineCustomProperty, QueryResult details