porcupine Documentation GitHub ↗
Porcupine v0.10.0 View Markdown ↗

PostHog JSONL import semantics#

The importer reads the events and persons JSONLines models from PostHog S3 batch exports. Upload decompressed UTF-8 JSONL through the resumable import API. This endpoint does not read Parquet, fetch remote URLs, or accept compressed upload chunks.

Event rows#

An event requires uuid, event, distinct_id, and timestamp. Properties may be a JSON object or a string containing a JSON object. Missing properties become {}. ISO 8601 timestamps retain their fractional precision in the timestamp column; UTC ClickHouse timestamp text is also accepted. The existing millisecond SQL columns retain their normal precision.

The export's team_id never chooses the destination project. The authenticated import job determines project scope. The job freezes its creator's authenticated source binding. An unbound creator uses the default PostHog namespace, where imported UUIDs deduplicate with live unbound PostHog UUIDs. A bound creator uses the existing source-scoped event ID derivation, matching live PostHog and native messages with that same source and message ID. Different source namespaces remain independent. Row fields cannot choose a source or project, and resume does not change the job namespace. The first accepted record wins; importing a duplicate cannot change a live record, and a later live duplicate cannot change an imported record.

elements_chain is retained as $elements_chain unless that property already exists. Historical reserved analytics names, including $autocapture, are preserved. Exported person_id and person_properties are not treated as current application identity: PostHog documents these event fields as ingestion-time snapshots.

A previously resolved cookieless_… daily ID is retained. The live cookieless request sentinel is rejected because an import cannot reconstruct its original day, browser, or IP. Known session, user, device, and address fields are removed from archived cookieless properties; ingestion credentials and address fields are not imported as event properties. Arbitrary business properties are not a complete personal-data scrub.

Persons and explicit identity evidence#

The persons model has one row per distinct_id, with person_id, properties, optional person_version, person_distinct_id_version, created_at, and is_deleted. Its internal PostHog person UUID is not an application user ID.

A valid persons row hydrates a profile on its original distinct ID without inventing an anonymous-to-user link. Such a profile remains unidentified until explicit identity evidence arrives. The importer records an unresolved identity count for these associations. A higher archive version from the same frozen source can update an import-owned profile. Versions from different PostHog sources are not compared. Without version fields, only the initial profile snapshot is applied. A subsequent live profile update or identity link prevents later archive writes. Stale versions and protected live profiles are ignored. A deleted-person snapshot can clear only a profile still owned by this importer; it cannot delete live person state.

An archived $identify can establish its explicit $anon_distinct_id link only when both sides are absent or still import-owned and no existing link conflicts. A target-only $identify can identify its stated ID without creating a link. Incremental $set and $set_once arguments are not replayed. Stored PostHog exports can omit those arguments; treating their absence as current traits would be incorrect. Such controls are retained as hidden, marked no-ops and reported as unresolved. Unsupported alias and group controls are hidden no-ops; this import does not reconstruct group identity. Persons snapshots provide the supported trait hydration path.

Delivery, progress, and storage#

Imported records use honest local acceptance time and is_imported=1. Their original event time is preserved. Interactive SQL and on-demand downloads include imported analytics history; automatic receipt-window S3 queries exclude it. Imports do not enqueue individual or batch webhooks. Hidden identity controls do not increase ordinary analytics event counts.

Each upload checkpoint commits its accepted records and byte offset together. Status counts distinguish accepted, duplicate, ignored, invalid, and unresolved rows. Unresolved is an identity diagnostic and can overlap accepted or ignored. Accepted means durable queue acceptance, not completed ClickHouse delivery; check the project's delivery status before querying a complete import. Cancellation or a failed file digest does not remove records accepted by earlier chunks. Invalid-row samples contain line numbers and static reasons, never row values.

The upload is bounded to 256 KiB and 1,000 rows per chunk, with a 1 MiB maximum row. Queue limits apply to imports too. Imports initially consume hot storage and follow the deployment's existing receipt-time cold-storage policy; this feature does not backdate receipts or change TTLs. Plan SSD capacity for the archive and import in batches if needed. Queue backpressure limits pending work, not the final size of retained historical data.