All posts

Event Schema Design: Naming, Properties, and Avoiding Analytics Debt

July 14, 20267 min read

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 SignupCompleted and signup_completed gives you two events for one thing.
  • No IDs in event names. product_viewed with a product_id property, never product_1234_viewed. Names are a low-cardinality dimension; the moment they carry identifiers, every grouping breaks.
  • No versions in names. checkout_started_v2 fragments 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. revenue as 149.00 in 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", not 1/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.

EventPropertyTypeRequiredNotes
checkout_completedorder_idstringyesUnique per order
checkout_completedrevenuenumberyesPost-discount, excl. tax
checkout_completedcurrencystringyesISO 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_action event with an action_type property. 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