An event's properties determine every question you can ask about it later — if plan_tier isn't attached to checkout_completed, you can never retroactively segment conversion by plan without a re-instrumentation cycle. Properties are not decoration on an event; they are the analysis surface itself. Get them wrong and you ship blind spots that only surface months later, when someone asks a question the data was never shaped to answer.
Quick Answer: Attach properties to the entity that actually owns the fact — event properties describe what happened this time (state at the moment), user properties describe who did it (durable identity traits), and group properties describe the account or team context. Skip anything free-text, unbounded, or unlikely to ever be a
GROUP BYclause.
Why event properties are really a question-design problem
Every property you attach is a future filter, breakdown, or join key — and every property you omit is a question you've pre-emptively answered "no" to. Think of the event schema as a contract with your future analytical self, not a log format.
This reframes the whole exercise. Instead of asking "what data is available at the moment this fires," the right question is "what will someone want to slice this by in six months?" That's a harder question, but it's the one that actually matters, and it's the same instinct behind building a tracking plan before you write instrumentation code rather than backfilling structure after events already exist in production.
A useful mental split, borrowed loosely from relational modeling: an event is a fact table row, and its properties are either measures of that specific occurrence or foreign keys into dimension tables (the user, the account, the object acted upon). Properties that belong on a dimension table don't belong duplicated onto every event row — that's where cardinality and redundancy problems both start.
The cost of getting this wrong is asymmetric
Missing a property is expensive but recoverable — you add it going forward and lose historical granularity. Adding the wrong property, especially a high-cardinality one, is a different failure mode entirely: it can degrade query performance and inflate warehouse cost for every downstream user of that event, indefinitely, until someone notices and does a cleanup migration.
That asymmetry argues for being conservative on cardinality, generous on the rubric test described later in this piece — when genuinely unsure, favor a bounded, typed property over an unbounded one, even if it means being slightly less granular.
Event, user, and group properties: where does each fact belong
Event, user, and group properties differ by lifespan and ownership: event properties describe a single occurrence and never change after the fact, user properties describe traits that persist and evolve across many events, and group properties describe the account or organization the user belongs to. Attaching a fact to the wrong level is the single most common instrumentation mistake.
Event properties: what happened, right now
Event properties are immutable once the event fires — they're a snapshot, not a live pointer. If subscription_status was trial when feature_used fired, that value should stay trial in the historical record even after the user upgrades tomorrow.
Typical event properties:
- Action detail —
button_label,search_query_length(not the raw query — see cardinality section),sort_order - Object reference —
document_id,project_id(a foreign key, not the object's full state) - Context at time of event —
plan_tier_at_event,referrer_channel,platform - Outcome/result —
status_code,duration_ms,items_returned_count
User properties: durable identity traits
User properties live on the user's profile and represent current-state truth, overwritten as it changes — signup_date, plan_tier (current, not historical), role, company_size_bucket, lifecycle_stage.
The distinction matters because analytics tools typically treat these differently at query time: event properties are queried as they were at the time of the event (historical), while user properties are usually joined in as they are right now (current). Confusing the two produces a specific, recurring bug: someone builds a cohort of "users who were on the free plan when they churned," using the current plan_tier user property, and gets zero results because everyone who churned has since been reset to a default tier.
Group properties: the account or team layer
For B2B products, most meaningful analysis happens at the account level, not the individual user level — a checkout_completed event is interesting, but "which accounts with 50+ seats churned in Q2" is the question that actually drives roadmap decisions. Group properties (account_id, seat_count, industry, contract_value_bucket, csm_owner) let you roll individual user events up to that level.
| Property level | Lifespan | Example | Typical query pattern |
|---|---|---|---|
| Event | Fixed at fire time, never changes | plan_tier_at_event, duration_ms | "What was true when this happened" |
| User | Mutable, reflects current state | plan_tier, role, signup_date | "Who does this now" |
| Group | Mutable, shared across all users in the account | seat_count, industry, contract_value_bucket | "Which accounts, rolled up" |
A single fact can legitimately need to exist at two levels — current plan tier as a user property for live segmentation, and plan-tier-at-event as an event property for historical funnel accuracy. That's not redundancy; it's answering two different questions.
The high-cardinality trap and how to spot it before it ships
High cardinality means a property has an unbounded or very large number of distinct possible values — free-text search queries, raw error messages, full URLs, timestamps used as dimensions, and user-generated content are the classic offenders. These properties blow up query performance and warehouse cost because most analytics engines build in efficient structures for low-cardinality grouping, and every unique value effectively becomes its own bucket.
The trap is that a high-cardinality property is usually the easiest one to add, because it's already sitting right there in the application state — the raw search string, the full stack trace, the exact click coordinates. Ease of access is not evidence of analytical value.
A concrete before-and-after
- Bad:
search_query: "best noise cancelling headphones under 200 dollars for travel"— every search is unique, so this can never be grouped meaningfully, and if it's PII-adjacent (a name, an email typed into a search box) it's also a data-handling liability. - Better:
search_query_length: 62,search_query_category: "electronics"(derived via a classifier or taxonomy at write time),search_result_count: 14— each is bounded, groupable, and still analytically useful. - Bad:
error_message: "TypeError: Cannot read property 'id' of undefined at line 342"— a distinct string per stack trace location and runtime state. - Better:
error_code: "E_NULL_REF",error_severity: "warning"— a small enum you control, mapped from the raw message at the point of instrumentation.
The high-cardinality checklist
Before shipping a property, run it through these checks:
- Bounded set? Can you enumerate the realistic distinct values (under a few hundred) today, or is the count effectively infinite?
- Free text? Anything typed by a human — search terms, comments, form input — is a strong candidate for transformation (bucket, length, category) rather than raw capture.
- PII risk? Free text frequently smuggles in names, emails, or other identifiers your privacy program didn't sign off on.
- Timestamp-as-dimension? A raw timestamp used to group by is really a time-bucket problem — truncate to hour/day/week at write or query time, never store it as an ungrouped dimension.
- IDs with no rollup? A raw UUID is fine as a join key but useless as a group-by; pair it with a bucketed or categorical companion property if segmentation matters.
The "will I ever slice by this?" rubric before adding a dimension
Before adding any property, ask a specific, falsifiable question: "Can I name a real report, dashboard, or decision that would use this as a GROUP BY or filter?" If the honest answer is "maybe, someday, for something," that's a signal to leave it out, not a green light to keep it "just in case."
Run every candidate property through this five-question rubric:
- Is there a named report this would power? Not a hypothetical — an actual dashboard, funnel, or cohort someone on the team has asked for or would recognize on sight.
- Is the cardinality bounded and known? If you can't estimate the number of distinct values within an order of magnitude, that's a red flag on its own.
- Does it belong at this level (event/user/group), or is it duplicating a dimension table? If the same fact is already a user or group property, don't re-attach it to every event unless you specifically need the point-in-time snapshot.
- Is it derivable later from other properties instead? If
total_priceandunit_priceare both captured, you don't also needdiscount_amountunless the derivation is genuinely ambiguous. - Does capturing it introduce a privacy or compliance question you haven't answered? Free text, precise geolocation, and anything resembling PII need an explicit answer, not a default yes.
A property that fails question 1 — "will I ever slice by this?" — should almost always be left off, even if it's technically available. The analytics instrumentation guide covers the broader event-design process this rubric slots into; this is the property-level filter that runs inside it.
Bounded, named-use properties compound in value over time as more reports reuse them. Speculative properties compound in cost over time as more rows accumulate under a schema nobody queries.
A worked payload: typed properties done deliberately
A well-designed checkout_completed event separates immutable event facts, a bounded set of enums, and foreign keys — with nothing free-text and nothing unbounded. This is a realistic shape a PM should be able to defend property-by-property against the rubric above.
{
"event": "checkout_completed",
"timestamp": "2026-07-10T14:32:00Z",
"properties": {
"order_id": "ord_9f2a1c",
"user_id": "usr_44210",
"account_id": "acct_7731",
"plan_tier_at_event": "growth",
"payment_method_type": "card",
"currency": "usd",
"order_total_bucket": "100_to_250",
"discount_applied": true,
"discount_type": "annual_prepay",
"item_count": 3,
"checkout_duration_seconds": 47,
"referrer_channel": "email_campaign",
"device_type": "desktop",
"is_first_purchase": false
}
}
Why each field earned its place
order_id,user_id,account_id— foreign keys, bounded by design (one per entity), and essential joins to the dimension tables where fuller detail lives.plan_tier_at_event— a bounded enum, deliberately named_at_eventto distinguish it from the mutable current-state user property with the same underlying fact.order_total_bucket— a bucketed range instead of a raw dollar amount, because "revenue by exact cent" is rarely the actual report; "revenue by band" almost always is. If exact totals matter for finance reconciliation, that belongs in a transactional system, not necessarily every analytics event.discount_type,payment_method_type,referrer_channel,device_type— all bounded enums with a known, small value set, each backing a real, nameable report (attribution by channel, conversion by device).checkout_duration_seconds— a numeric measure, useful for friction analysis, and naturally bucketable at query time without needing to be bucketed at write time.- Deliberately excluded: raw billing address, full card metadata beyond
payment_method_type, free-text discount codes (captured instead as a categorizeddiscount_type), and precise geolocation — none passed the rubric or the PII check.
How this connects to naming, metrics, and the wider tracking plan
Property design doesn't happen in isolation — it depends on decisions made earlier in the instrumentation process and feeds decisions made later. Skipping the upstream steps is why teams end up with the same property spelled three different ways across events.
A consistent event naming taxonomy is the precondition for property consistency: if checkout_completed and purchase_finished both exist for the same action, their properties will drift independently too, and no query can reliably union them. Properties should be standardized in a shared dictionary — the same plan_tier enum values, the same bucket boundaries — reused across every event that needs them, not reinvented per event.
Properties also need to trace up to what actually matters strategically. If your team has defined a north star metric, the properties on your core events should be the ones capable of explaining movement in that metric — segment, channel, and cohort breakdowns the north star's supporting metrics actually need. And because most meaningful product questions map back to the customer's own goals, cross-checking candidate properties against a jobs-to-be-done framing, or against stages in the customer journey, is a fast way to catch properties that matter to engineering but never surface in a business review.
Key Takeaways
- Attach properties to the entity that owns the fact — event properties are point-in-time and immutable, user properties are current-state and mutable, group properties roll individual users up to the account level.
- The same fact can legitimately live at two levels (e.g.,
plan_tieras both a current user property andplan_tier_at_eventas a historical event property) — that's not redundancy, it answers two different questions. - High cardinality is the most expensive mistake in event design — free text, raw timestamps, and unbounded IDs used as dimensions inflate cost and rarely support a real report.
- Run every candidate property through the rubric: named report, bounded cardinality, correct level, not derivable elsewhere, and no unresolved privacy question.
- Bucket and categorize at write time where possible (
order_total_bucket,discount_type) rather than shipping raw values you'll have to transform downstream anyway. - Property design depends on upstream naming consistency and downstream metric clarity — a shared taxonomy and a clear north star both shape which properties are worth attaching.
- Tools like Prodinja's Data Modelling, which maps entities and attributes to SQL DDL, can make the event-vs-user-vs-group decision concrete before instrumentation code gets written.
Frequently Asked Questions
What's the difference between an event property and a user property?
An event property is fixed at the moment the event fires and never changes retroactively, like plan_tier_at_event. A user property lives on the profile and reflects current state, like plan_tier, and is typically overwritten as the user's situation changes.
How many properties should one event have?
There's no universal number, but most well-designed events land in the 8-15 property range — enough to support real reports, few enough that every property can be individually justified against the "will I ever slice by this?" rubric. If you're past 20, audit for properties duplicating dimension-table data.
Should I track raw search queries or user-generated text as event properties?
Generally no — raw free text is high-cardinality, often carries PII risk, and rarely supports a groupable report on its own. Transform it at write time into bounded derivatives like search_query_length, search_result_count, or a classified search_query_category instead.
What is cardinality in analytics, and why does it matter for event properties?
Cardinality is the number of distinct values a property can take. High-cardinality properties (unique per event, like raw timestamps or free text) can't be meaningfully grouped and tend to inflate query cost and storage, so most well-designed schemas keep event properties bounded to enums, buckets, or IDs paired with a categorical companion field.
Can the same property exist as both an event property and a user property?
Yes, and it often should when you need both historical accuracy and live segmentation — for example, plan_tier_at_event preserves what was true when a specific event fired, while the plan_tier user property reflects the account's current tier for present-day cohort building.