How PostHog Works
PostHog is an event-based product analytics platform. Every user interaction is stored as an event with a timestamp, a distinct_id (user identifier), and a set of key-value properties. Events flow through an ingestion pipeline into ClickHouse (cloud) or PostgreSQL/ClickHouse (self-hosted), where they are queryable via the PostHog UI, HogQL, or the API.
The data model has three core entities:
- Events -- Timestamped actions with arbitrary properties. Schema-on-read; no predefined schema required.
- Persons -- Identified by
distinct_id. Multiple distinct_ids can be merged into a single person viaposthog.identify()orposthog.alias(). - Groups -- Optional entity for account-level or organization-level analytics. Events can be associated with a group in addition to a person.
Autocapture records clicks, form submissions, and pageviews automatically by instrumenting DOM events. Custom events supplement autocapture for business-specific actions like purchases or feature usage.
Feature flags evaluate on the client or server side. The PostHog SDK fetches flag values on initialization and caches them locally. Server-side evaluation uses the /decide endpoint or local evaluation via the feature flag definitions file.
Session recordings capture DOM mutations via a MutationObserver-based approach (rrweb), compressing snapshots and streaming them to PostHog for playback.
Installing the Tracking Script
JavaScript (Browser)
Load PostHog.js via the snippet or npm:
<script>
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://us.i.posthog.com', // or https://eu.i.posthog.com, or your self-hosted URL
person_profiles: 'identified_only'
});
</script>
npm / yarn
npm install posthog-js
import posthog from 'posthog-js';
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only',
autocapture: true, // DOM event capture
capture_pageview: true, // automatic $pageview events
capture_pageleave: true, // $pageleave on unload
session_recording: {
maskAllInputs: true, // redact form fields
maskTextSelector: '.sensitive'
}
});
Node.js (Server-Side)
npm install posthog-node
const { PostHog } = require('posthog-node');
const client = new PostHog('YOUR_PROJECT_API_KEY', {
host: 'https://us.i.posthog.com'
});
client.capture({
distinctId: 'user-123',
event: 'subscription_created',
properties: { plan: 'pro', mrr: 49.00 }
});
await client.shutdown(); // flush before exit
Self-Hosted (Docker)
git clone https://github.com/PostHog/posthog.git
cd posthog
docker compose -f docker-compose.hobby.yml up -d
Point your SDK's api_host to http://your-server:8000. For production, deploy via the Helm chart on Kubernetes:
helm repo add posthog https://posthog.github.io/charts-clickhouse/
helm install posthog posthog/posthog \
--set cloud=aws \
--set ingress.hostname=posthog.example.com
Event Tracking and Data Collection
Custom Events
// Track a purchase event
posthog.capture('purchase_completed', {
product_id: 'sku-4421',
product_name: 'Widget Pro',
price: 29.99,
currency: 'USD',
quantity: 2
});
// Track with a timestamp (backfilling)
posthog.capture('import_event', {
$timestamp: '2025-12-01T10:00:00Z',
source: 'csv_import'
});
// Track with groups (requires group analytics)
posthog.capture('report_exported', {
format: 'pdf',
$groups: { company: 'company-abc' }
});
Autocapture Events
PostHog automatically captures these event types when autocapture: true:
| Event | Trigger |
|---|---|
$autocapture |
Click on <a>, <button>, <input>, <select>, <textarea>, or elements with [role=button] |
$pageview |
Page load (if capture_pageview: true) |
$pageleave |
Page unload |
$rageclick |
3+ clicks on the same element within 5 seconds |
Autocapture properties include $element_tag, $element_text, $element_href, and CSS selectors. Disable autocapture on specific elements:
<button data-ph-no-capture>Do Not Track This</button>
Super Properties
Set properties that attach to every subsequent event:
posthog.register({
app_version: '2.4.1',
environment: 'production'
});
// Set once (only if not already set)
posthog.register_once({ first_seen_version: '2.4.1' });
// Remove a super property
posthog.unregister('environment');
Identity and User Tracking
Identifying Users
Connect anonymous and authenticated sessions:
// Before login: PostHog uses an anonymous distinct_id (UUID)
// After login: identify merges the anonymous ID with the real user ID
posthog.identify('user-db-id-123', {
email: 'user@example.com',
name: 'Jane Doe',
plan: 'enterprise'
});
After identify(), PostHog merges the anonymous person record with the identified person. All prior events from the anonymous session are retroactively associated with the identified user.
Aliasing
Link two known IDs (e.g., pre- and post-migration user IDs):
posthog.alias('new-user-id', 'old-user-id');
Resetting Identity
On logout, reset to generate a new anonymous ID:
posthog.reset();
Group Analytics
Associate events with an organization or account:
// Assign the current user to a group
posthog.group('company', 'company-abc', {
name: 'Acme Corp',
industry: 'SaaS',
employee_count: 150
});
// Events now include company group context
posthog.capture('feature_used', { feature: 'dashboard' });
Group analytics enable queries like "which companies have the highest activation rate" rather than just individual user metrics.
Person Properties
Set properties on the person record directly:
posthog.people.set({ subscription_status: 'active' });
posthog.people.set_once({ first_purchase_date: '2025-06-15' });
API and Data Export
HogQL Queries
PostHog exposes a SQL-like query language (HogQL) over the events table:
-- Top 10 events by volume in the last 7 days
SELECT event, count() as cnt
FROM events
WHERE timestamp > now() - interval 7 day
GROUP BY event
ORDER BY cnt DESC
LIMIT 10
-- Funnel: signup to purchase within 7 days
SELECT
person_id,
min(if(event = 'signed_up', timestamp, null)) as signup_time,
min(if(event = 'purchase_completed', timestamp, null)) as purchase_time
FROM events
WHERE event IN ('signed_up', 'purchase_completed')
AND timestamp > now() - interval 30 day
GROUP BY person_id
HAVING signup_time IS NOT NULL
AND purchase_time IS NOT NULL
AND purchase_time - signup_time < interval 7 day
Execute via API:
curl -X POST 'https://us.i.posthog.com/api/projects/PROJECT_ID/query/' \
-H 'Authorization: Bearer phx_your_personal_api_key' \
-H 'Content-Type: application/json' \
-d '{
"query": {
"kind": "HogQLQuery",
"query": "SELECT event, count() FROM events WHERE timestamp > now() - interval 1 day GROUP BY event ORDER BY count() DESC LIMIT 5"
}
}'
Events API
Retrieve raw events:
# List events for a person
curl 'https://us.i.posthog.com/api/projects/PROJECT_ID/events/?person_id=USER_ID&limit=100' \
-H 'Authorization: Bearer phx_your_personal_api_key'
Batch Export API
Export events to a data warehouse:
# Create a batch export to S3
curl -X POST 'https://us.i.posthog.com/api/projects/PROJECT_ID/batch_exports/' \
-H 'Authorization: Bearer phx_your_personal_api_key' \
-H 'Content-Type: application/json' \
-d '{
"name": "S3 Export",
"destination": {
"type": "S3",
"config": {
"bucket_name": "my-posthog-exports",
"region": "us-east-1",
"prefix": "posthog/",
"aws_access_key_id": "AKIA...",
"aws_secret_access_key": "..."
}
},
"interval": "hour"
}'
Feature Flags API
# Evaluate flags for a user
curl -X POST 'https://us.i.posthog.com/decide/?v=3' \
-H 'Content-Type: application/json' \
-d '{
"api_key": "YOUR_PROJECT_API_KEY",
"distinct_id": "user-123"
}'
// Client-side flag check
if (posthog.isFeatureEnabled('new-checkout-flow')) {
renderNewCheckout();
} else {
renderLegacyCheckout();
}
// Multivariate flag
const variant = posthog.getFeatureFlag('pricing-page-test');
// Returns 'control', 'variant-a', or 'variant-b'
// Flag with payload
const config = posthog.getFeatureFlagPayload('onboarding-steps');
// Returns JSON payload attached to the flag
Common Issues
Events Not Appearing
Check initialization order. PostHog must initialize before any capture() calls:
// Wrong: capture before init
posthog.capture('test_event');
posthog.init('key', { api_host: '...' });
// Correct: init first
posthog.init('key', { api_host: '...' });
posthog.capture('test_event');
Verify the network request reaches PostHog. Open DevTools > Network, filter for i.posthog.com. A 200 response confirms delivery. A 401 means the API key is wrong.
Autocapture Missing Elements
Autocapture only instruments specific tags: a, button, form, input, select, textarea, and elements with role="button". Clicks on <div> or <span> elements are not captured unless they have a matching role attribute.
To capture clicks on custom elements, add the attribute:
<div role="button" class="custom-cta">Click Me</div>
Distinct ID Conflicts
If a user logs in on two different browsers, posthog.identify() merges those anonymous IDs into one person. If you call identify() with different real user IDs from the same browser without calling reset() first, the second user's events will be attributed to the first user.
Always call posthog.reset() on logout before identifying a new user.
Self-Hosted ClickHouse Memory
Self-hosted ClickHouse can OOM on large queries. Set memory limits in docker-compose.yml:
clickhouse:
environment:
- MAX_MEMORY_USAGE=8000000000 # 8GB per query
- MAX_MEMORY_USAGE_FOR_ALL_QUERIES=12000000000
Session Recordings Not Playing
Recordings require the rrweb library bundled with PostHog.js. If you load PostHog via a custom build that strips rrweb, recordings will not function. Also verify session_recording is not disabled in posthog.init():
posthog.init('key', {
session_recording: {
// Ensure this is NOT set to false:
// disable_session_recording: true // remove this line
}
});
Content Security Policy headers can also block recording. Add connect-src and script-src for your PostHog host.
Platform-Specific Considerations
Experiments (A/B Testing)
Experiments are built on feature flags. Create an experiment in the PostHog UI, which generates a multivariate flag. The SDK returns a variant string:
const variant = posthog.getFeatureFlag('checkout-experiment');
if (variant === 'test') {
showNewCheckoutLayout();
} else {
showControlLayout();
}
// Track the goal metric
posthog.capture('checkout_completed', { revenue: 59.99 });
PostHog calculates statistical significance using a Bayesian model and reports results on the experiment page.
Surveys
Trigger in-product surveys based on user behavior:
// Surveys are configured in the PostHog UI
// The SDK automatically renders them when conditions match
// To manually check for available surveys:
posthog.getActiveMatchingSurveys((surveys) => {
console.log('Active surveys:', surveys);
});
Data Warehouse Imports
PostHog can import data from external sources (Stripe charges, Salesforce contacts, Zendesk tickets) into the data warehouse, making it queryable alongside event data in HogQL:
-- Join PostHog events with imported Stripe charges
SELECT e.person_id, s.amount, s.currency
FROM events e
JOIN stripe_charges s ON e.properties.$stripe_customer_id = s.customer_id
WHERE e.event = 'purchase_completed'
Rate Limits
The capture endpoint accepts up to 1,000 events per batch request. The API has per-project rate limits: 240 requests/minute for the query API, 600 requests/minute for the events API. Self-hosted deployments have no rate limits beyond infrastructure capacity.