6 min read

How to Calculate Unique Users in PostHog

Understanding your unique user count is core to product analytics, but PostHog's distinct_id system can be confusing if you're not careful. Get this wrong and you'll either undercount (if users have multiple IDs) or overcount (if you're tracking anonymous + identified separately). Here's how to do it right.

Setting Up Unique User Tracking

PostHog identifies users through a distinct_id. You assign this when initializing the SDK or capturing events. The distinct_id is what PostHog uses to count uniques.

Install and initialize PostHog

Install the PostHog SDK and call PostHog.init() with your API key (find it in Settings > API Keys in your PostHog instance). This sets up event capture for your entire app.

javascript
import PostHog from 'posthog-js'

PostHog.init('phc_your-api-key-here', {
  api_host: 'https://your-instance.posthog.com'
})
Initialize PostHog at app startup

Identify users with a stable ID

When a user logs in or their identity becomes known, call PostHog.identify() with a stable identifier (user ID, email, phone—whatever is unique and permanent). This is the distinct_id PostHog uses to count them as one unique user.

javascript
PostHog.identify('user-12345', {
  email: '[email protected]',
  name: 'Alice Johnson',
  plan: 'enterprise',
  signup_date: '2024-01-15'
})
Identify a user after authentication—all properties become mergeable filters

Capture events tied to that user

Once identified, every PostHog.capture() call is attributed to that distinct_id. PostHog counts this as one unique user even if they generate dozens of events.

javascript
PostHog.capture('dashboard_viewed', {
  dashboard_id: 'dash-456',
  time_spent_seconds: 120
})

PostHog.capture('report_exported', {
  format: 'pdf',
  num_pages: 45
})
All events are attributed to the identified user
Tip: If you don't call identify(), PostHog auto-assigns a browser-based distinct_id to anonymous users. Once you call identify(), PostHog merges the anonymous history with the new identified user—this is automatic, no manual action needed.

Querying Your Unique User Count

PostHog gives you multiple ways to see unique user numbers: the visual Insights interface for quick charts, or SQL for precise, filtered queries.

Create a Trends insight in the UI

Go to Insights > New Insight, select Trends, and set the metric to Unique users. Choose your date range (last 7 days, month-to-date, etc.) and PostHog graphs your unique count over time.

javascript
// Fetch user trends via PostHog Insights API
const response = await fetch(
  'https://your-instance.posthog.com/api/projects/{project_id}/insights/trend/',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${POSTHOG_PERSONAL_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      events: [{ id: 'unique_users' }],
      date_from: '2024-03-01',
      date_to: '2024-03-31'
    })
  }
)
const data = await response.json()
Programmatically fetch unique user trends from the API

Write a SQL query for custom filtering

Go to Insights > SQL and write raw SQL against PostHog's events table. This lets you filter by event type, user properties, geography, or any other dimension.

javascript
// Count unique users in the last 30 days
const query = `
SELECT COUNT(DISTINCT distinct_id) as unique_users
FROM events
WHERE timestamp >= NOW() - INTERVAL 30 DAY
`

// Or execute via API
const response = await fetch(
  'https://your-instance.posthog.com/api/projects/{project_id}/query/',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${POSTHOG_PERSONAL_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ query, query_type: 'SQL' })
  }
)
const result = await response.json()
Execute SQL queries against your events table directly

Filter by specific events or user properties

Narrow your unique count to users who did something specific. Count users who purchased, users in a specific country, or users with a particular plan level.

javascript
// Count unique users who completed a purchase in the last 7 days
SELECT COUNT(DISTINCT distinct_id) as paying_users
FROM events
WHERE event = 'purchase'
  AND timestamp >= NOW() - INTERVAL 7 DAY

-- Or by user property (country from person object)
SELECT COUNT(DISTINCT e.distinct_id) as us_users
FROM events e
JOIN persons p ON e.person_id = p.id
WHERE p.properties->>'country' = 'US'
  AND e.timestamp >= NOW() - INTERVAL 30 DAY
Filter unique users by event name or user properties
Watch out: If you're using COUNT(DISTINCT distinct_id) and a user has both anonymous and identified activity, they may appear in your data twice until identify() merges them. This is temporary—once merged, they're counted as one.

Understanding Anonymous vs. Identified Users

PostHog tracks both anonymous and identified users. You need to understand the difference to avoid double-counting.

Know the difference between distinct_id and person_id

A distinct_id is the identifier you assign (via identify())—your user ID, email, or phone. Before identify(), PostHog assigns a browser-based distinct_id. The person_id is PostHog's internal ID for a person (created after identify() links activity together).

javascript
// Count ONLY identified users (those you've called identify() on)
SELECT COUNT(DISTINCT person_id) as identified_users
FROM events
WHERE person_id IS NOT NULL
  AND timestamp >= NOW() - INTERVAL 30 DAY

-- Count ALL distinct_ids (including anonymous)
SELECT COUNT(DISTINCT distinct_id) as total_tracked
FROM events
WHERE timestamp >= NOW() - INTERVAL 30 DAY
person_id counts identified users; distinct_id counts everyone

Merge anonymous and identified activity

When you call PostHog.identify(), PostHog automatically merges the anonymous distinct_id with the new identified user. Their event history is linked—no manual action needed. This happens in the background.

javascript
// User lands anonymously—PostHog auto-assigns a distinct_id
// They browse for 10 minutes

// They sign up; you now know their ID
PostHog.identify('user-789', {
  email: '[email protected]'
})

// From this point on, all events (old and new) are merged under 'user-789'
// PostHog automatically created the link—no extra call needed
Activity before and after identify() is merged automatically

Common Pitfalls

  • Forgetting to call identify() on login—anonymous users are counted as separate uniques, inflating your actual user count.
  • Using inconsistent distinct_ids (sometimes user ID, sometimes email, sometimes a session ID)—this fragments users across multiple IDs and breaks your counts.
  • Not filtering out test/bot events—your dev team, QA, and seed data are counted as real users unless you explicitly exclude them in queries.
  • Calling identify() too late—if you identify a user after they trigger events, their pre-identification activity may not merge cleanly depending on your implementation.

Wrapping Up

You now know how PostHog counts uniques: via distinct_id on the front end, merged with person_id for identified users, and queryable via Insights or SQL. The critical move is consistent identification—call identify() early, use the same stable ID throughout the user's lifetime, and filter test data out of your queries. If you want to track this automatically across tools, Product Analyst can help.

Track these metrics automatically

Product Analyst connects to your stack and surfaces the insights that matter.

Try Product Analyst — Free