Event Schema Design: Naming, Properties, and Avoiding Analytics Debt
Analytics debt is worse than technical debt
Bad code can be refactored. Bad event data cannot — you cannot retroactively fix eighteen months of events named button_click_2. Whatever taxonomy you ship is the taxonomy you analyze for as long as that history matters.
The good news is that most of the value comes from a handful of decisions made once.
Name events object-action, in the past tense
Pick one convention and never deviate:
object_action signup_completed, checkout_started, video_played
Object first, because it sorts usefully. In an alphabetical list of 200 events, checkout_started / checkout_completed / checkout_abandoned cluster together, while started_checkout / completed_checkout scatter across the alphabet.
Past tense, because an event is a record of something that already happened. signup_completed, not complete_signup.
Rules that prevent 90% of taxonomy rot:
- snake_case everywhere. Mixing
SignupCompletedandsignup_completedgives you two events for one thing. - No IDs in event names.
product_viewedwith aproduct_idproperty, neverproduct_1234_viewed. Names are a low-cardinality dimension; the moment they carry identifiers, every grouping breaks. - No versions in names.
checkout_started_v2fragments a metric permanently.
Properties carry the detail
The event name answers what happened. Properties answer everything else.
{
"event": "checkout_completed",
"properties": {
"order_id": "ord_8813",
"revenue": 149.00,
"currency": "USD",
"item_count": 3,
"payment_method": "card",
"coupon_applied": true,
"checkout_variant": "one_page"
}
}Design guidance:
- Type-stable. A property must always be the same type.
revenueas149.00in one event and"149.00"in another will break aggregations, and columnar engines will either reject the row or widen the column to text. - Units in the name.
duration_ms,revenue_usd,file_size_bytes. "Is this seconds or milliseconds?" should never be a question someone has to answer by reading tracking code. - Booleans stay boolean. Not
"yes"/"no", not1/0. - Watch cardinality. A property with millions of distinct values (a full URL with query string, a raw user agent) is not a useful dimension. Store the parsed pieces.
- Never put PII in properties. Emails, names, and raw IPs in an event payload turn your analytics store into a system of record for personal data, with every obligation that implies.
Storing flexible properties in a columnar engine
Events have a fixed core and a variable tail. The pragmatic layout promotes the columns you filter on constantly and keeps the rest as JSON:
CREATE TABLE events ( id BIGINT PRIMARY KEY, site_id INTEGER NOT NULL, timestamp TIMESTAMP NOT NULL, visitor_id VARCHAR NOT NULL, session_id VARCHAR NOT NULL, event_name VARCHAR NOT NULL, path VARCHAR, properties JSON );
DuckDB queries JSON directly:
SELECT properties->>'payment_method' AS method,
sum((properties->>'revenue')::DOUBLE) AS revenue
FROM events
WHERE event_name = 'checkout_completed'
AND timestamp >= current_date - 30
GROUP BY method
ORDER BY revenue DESC;When a JSON property becomes hot — queried in most dashboard loads — promote it to a real column. Extracting from JSON on every row is meaningfully slower than reading a native typed column, and you lose the compression benefits of a low-cardinality column.
Version the schema, not the event name
When checkout_completed needs to change shape, add a schema_version property rather than minting checkout_completed_v2:
{ "event": "checkout_completed", "properties": { "schema_version": 2, … } }The metric stays continuous. Queries that care about the difference can branch on the version; queries that just count checkouts keep working.
Additive changes — new optional properties — need no version bump at all. Reserve versioning for changes in meaning: a property that changed units, or a definition that shifted.
Maintain a tracking plan
A tracking plan is a single document listing every event, its properties, their types, and who owns it. It can be a Markdown table in the repo. What matters is that it exists and that adding an event means editing it.
| Event | Property | Type | Required | Notes |
|---|---|---|---|---|
| checkout_completed | order_id | string | yes | Unique per order |
| checkout_completed | revenue | number | yes | Post-discount, excl. tax |
| checkout_completed | currency | string | yes | ISO 4217 |
Validate against it in CI if you can. Even an unvalidated plan prevents the most common failure: two engineers instrumenting the same action differently, six months apart.
The anti-patterns, listed
- The kitchen-sink event. One
user_actionevent with anaction_typeproperty. You have re-implemented event names, worse, and lost every tool's ability to reason about them. - Tracking everything. 400 events nobody has ever queried is not thoroughness; it is noise that makes the 12 events that matter harder to find.
- Client-side revenue. Money events belong on the server, where they cannot be blocked, spoofed, or double-fired by a retry.
- Renaming in place. Renaming an event splits its history at the rename date. If you must, keep emitting both for a transition window.
Frequently asked questions
What naming convention should I use for events?
Object-action in the past tense — checkout_completed, signup_started. It sorts related events together, reads unambiguously in a query, and avoids the drift that free-form naming produces across teams.
Should details be separate events or properties?
Properties. button_clicked with a label property stays queryable as one series; a distinct event per button produces hundreds of names nobody can aggregate across. Reserve new event names for genuinely different actions.
How do I version an event schema?
Version the schema, not the name. Adding checkout_completed_v2 splits your history permanently. Carry a schema version property and keep changes additive so old rows stay readable.
What is a tracking plan and do I need one?
A single document listing every event, its properties, and their types. Yes — without it, two engineers implement the same event differently within a quarter and neither realizes until the numbers disagree.
How should flexible properties be stored in a columnar engine?
Promote the properties you query often to real columns so they compress and prune; keep the long tail in a JSON or MAP column. Querying everything out of one JSON blob forfeits most of what columnar storage offers.
Start small
Ten well-defined events beat two hundred vague ones. Instrument the handful of actions tied to your actual goals, name them consistently, document them, and add more only when a specific question demands it. The taxonomy you can explain in one page is the one that survives. Related: star-schema modelling and privacy-first schema design.
Try InsightsTrack free
Open-source, self-hosted, privacy-first analytics — explore the live demo, no install required.
Open live dashboard