Open the event list of any analytics project that has been running for a year without a convention. You will find something like this:
signup
Signup
signup_completed
user_signed_up
userSignedUp
Sign Up Completed
signup_success
signup_v2
Eight series. Each one is real data. None of them covers the whole year, the funnel someone built in March silently stopped counting in June, and the person who knew which was which left in September. Nobody can answer "how many people signed up last quarter" without a forensic exercise, so the team stops asking the question, which is the actual cost of bad naming, not the untidiness.
This does not happen through carelessness. It happens because every event is added by a different person, in a different sprint, in a hurry, with no mechanism that shows them what already exists.
The convention: object_verb, past tense, snake_case
subscription_started
checkout_completed
project_created
invite_accepted
onboarding_step_completed
Four rules, each earning its place:
Object first. Sorting the event list alphabetically then groups everything
about the same object together: every subscription_* event sits in one block.
With verb-first names (created_project, completed_checkout) related events
scatter across the alphabet, and a list of 200 events becomes unusable.
Past tense. Events are records of things that already happened. Past tense
also reads correctly in every funnel step label and every insight title, and it
quietly stops people instrumenting intentions as if they were outcomes:
subscription_start is ambiguous, subscription_started is not.
snake_case. One casing, chosen so that nobody has to remember whether this
project uses camelCase or Title Case. The specific choice matters less than
having exactly one; snake_case matches PostHog's own $pageview style and
survives being pasted into SQL.
At least two words. clicked, error and viewed are not events. If the
name does not contain an object, the event cannot be interpreted without reading
the code that fires it.
Never put a value in the name
The most expensive naming mistake is encoding data into names:
plan_pro_purchased
plan_team_purchased
plan_enterprise_purchased
onboarding_step_1_completed
onboarding_step_2_completed
Every new plan silently breaks every chart, because a chart built on the three names you had in January cannot know about the fourth you added in April. The same information as properties is one series with a breakdown:
subscription_started: { plan: string; interval: "month" | "year"; trial: boolean }
onboarding_step_completed: { step: number; step_name: string }
Now "revenue by plan" is a breakdown, adding a plan requires no chart changes, and the funnel keeps its history.
The same applies to ids, emails, URLs and timestamps: they are properties, never names. A name is a category; a category with a million members is not a category.
Names come from the domain, not the interface
Name the object the way your database and your team name it. If the schema says
project, the events say project_created, even if the current UI calls it a
"workspace" and marketing calls it a "board". Interfaces get renamed roughly
once a year; a rename that touches your event names costs you your history,
while a rename that touches only labels costs nothing.
The mechanism: make the catalogue a type
Conventions written in a wiki decay. A convention the compiler enforces does not. Declare every event in one file:
// src/lib/analytics/events.ts
export interface EventCatalogue {
/** A visitor started the sign-up form. Browser event. */
signup_started: { source: "pricing" | "home" | "docs" | "direct" };
/** The account row exists. Server event, fired after the write. */
signup_completed: { method: "email" | "oauth"; invited: boolean };
/** Money moved. Server event, from the payment webhook. */
subscription_started: { plan: string; interval: "month" | "year"; trial: boolean };
}
export type EventName = keyof EventCatalogue & string;
export type EventProperties<N extends EventName> = EventCatalogue[N];
export const EVENT_NAME_PATTERN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
Then make the only capture function in the codebase take those types:
export function capture<N extends EventName>(name: N, properties: EventProperties<N>): void {
posthog.capture(name, scrubProperties(properties));
}
Now capture("signup") does not compile. Neither does
capture("signup_completed", { method: "magic-link" }). The five-spellings
problem cannot recur, because the second spelling is a type error in the editor,
before the branch is even pushed.
Add one more field and the catalogue also documents itself:
export const EVENT_QUESTIONS: Record<EventName, string> = {
signup_started: "How many visitors reach the form, and from which surface?",
signup_completed: "What share of started sign-ups become accounts, by method?",
subscription_started: "How many paid subscriptions started, on which plan?",
};
The Record<EventName, string> type forces one sentence per event. That single
sentence is what stops a catalogue rotting: an event nobody can justify in a
line is an event nobody will trust in a year, and the moment you cannot write
the sentence is the moment to not add the event.
A test to keep the convention honest
import { expect, test } from "vitest";
import { EVENT_NAME_PATTERN, EVENT_QUESTIONS, eventNames } from "@/lib/analytics/events";
test("every event follows object_verb snake_case", () => {
for (const name of eventNames()) {
expect(name, `${name} must be object_verb, past tense, snake_case`).toMatch(EVENT_NAME_PATTERN);
}
});
test("every event documents the question it answers", () => {
for (const name of eventNames()) {
expect(EVENT_QUESTIONS[name].length, `${name} needs a real question`).toBeGreaterThan(20);
}
});
Twenty lines, and the convention is now enforced by CI-free local verification rather than by whoever happens to review the PR.
When you have to rename anyway
Sometimes the name really is wrong. Renaming splits history: charts on the old name stop, charts on the new one start. Do it deliberately:
- Fire both names for one full reporting period: a week, a month, whatever your longest routine report covers.
- Migrate every insight, funnel and dashboard to the new name during that window. PostHog's "used in" list on the event tells you what depends on it.
- Delete the old capture call, and write the change down in a solution doc. Somebody will see the seam in a graph two quarters from now and needs to find the explanation before they treat it as a product event.
The pattern to steal
One file that declares every event with its exact property types. One capture function that takes those types. One sentence per event saying what question it answers. A regex test over the names. Everything else (the dashboards, the funnels, the answers you actually wanted) follows from that.