PostHog event data can be large and property-rich. Separate extraction from transformation so the migration can resume after failures and so you can review reserved PostHog properties before they enter your permanent GraphJSON schema.
Read the shared migration workflow first.
Choose an export path#
PostHog’s official migration documentation recommends testing a small import and separating export from import with durable object storage for resumability.
For a continuing export pipeline, use PostHog Batch Exports. For a bounded pilot, the official Query API can run a narrowly scoped HogQL query over events. The right path depends on PostHog Cloud versus self-hosted deployment, project region, history size, and available credentials.
Whichever path you use, stage immutable files and record:
- PostHog project, host, and region
- query or batch-export definition
- UTC extraction interval
- file checksums and row counts
- minimum and maximum event timestamps
- export cursor or checkpoint
Do not make the GraphJSON importer depend on a live PostHog request succeeding at the same moment.
Map the fields#
| PostHog field | GraphJSON target | Notes |
|---|---|---|
event |
event |
Rename only through a reviewed mapping |
timestamp |
request timestamp |
Parse the source time and convert to Unix seconds |
distinct_id |
user_id or anonymous_id |
Classify using your application identity rules |
uuid |
event_id |
Prefix with posthog: |
selected properties |
target event fields | Allowlist and type-check |
| group properties | account_id or named group ID |
Confirm which group type is the tenant |
PostHog properties beginning with $ often carry capture, URL, device, session, feature-flag, or SDK metadata. Some are valuable; none should be imported by default without a use case and privacy review.
Transform one record#
function toGraphJSON(source) {
const occurredAt = Date.parse(source.timestamp);
if (!source.event || !Number.isFinite(occurredAt)) {
throw new Error("missing event or timestamp");
}
const props = source.properties || {};
return {
timestamp: Math.floor(occurredAt / 1000),
body: {
event: source.event,
event_id: source.uuid
? `posthog:${source.uuid}`
: undefined,
user_id:
typeof source.distinct_id === "string"
? source.distinct_id
: undefined,
account_id:
typeof props.account_id === "string"
? props.account_id
: undefined,
plan: typeof props.plan === "string" ? props.plan : undefined,
schema_version: 1,
migration_source: "posthog",
},
};
}
In real code, decide whether distinct_id is authenticated before assigning it to user_id. Remove undefined fields and validate against the same target schema used by new events.
Treat persons and groups as separate models#
Person properties and group properties may be snapshots that change independently of an event. Decide whether an analysis needs the property value at event time or current state.
For event-time analysis, import an approved snapshot property into the event. For current state, keep the application database as the authority or model explicit state-change events. Do not copy a mutable profile into every event merely because it exists in the export.
PostHog’s person merging and alias behavior are not automatically reproduced by GraphJSON. Preserve known and anonymous IDs separately or create an explicit linking model. See Users, accounts, and identity.
Reconcile the result#
Compare raw source rows to accepted and quarantined rows, then check:
- counts by event and UTC day
- unique
uuidand importedevent_id - missing and empty
distinct_id - counts by key group or account
- required-property type failures
- selected feature-flag or session fields if they remain in scope
- complete journeys for several known users and accounts
PostHog insights can include person merges, property filters, sessions, and other analysis semantics. Rebuild each important metric intentionally; raw event parity alone does not recreate every report definition.
Cutover checklist#
- Export and import are separated by durable, encrypted staging.
- The source host, project, and region are correct.
-
$-prefixed properties have an explicit allowlist. -
distinct_id, persons, and groups have documented target semantics. - Source
uuidis preserved for deduplication. - A bounded pilot is complete before widening the date range.
- GraphJSON counts reconcile to raw exports and business totals.
- PostHog credentials and staged data have a retirement date.