API queries enable you to query your data in PostHog. This is useful for:
- Building user or customer-facing analytics.
- Pulling aggregated PostHog data into your own or other apps.
When should you not use API queries?
- When you want to export large amounts of data. Use batch exports instead.
- When you want to send data to destinations like Slack or webhooks immediately. Use real-time destinations instead.
Prerequisites
Using API queries requires:
- A PostHog project and its project ID which you can get from your project settings.
- A personal API key for your project with the Query Read permission. You can create this in your user settings.
Creating a query
To create a query, you make a POST
request to the /api/projects/:project_id/query/
endpoint. The body of the request should be a JSON object with a query
property with a kind
and query
property.
For example, to create a query that gets events where the $current_url
contains blog, you use kind: HogQLQuery
and SQL like:
curl \-H 'Content-Type: application/json' \-H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" \<ph_app_host>/api/projects/:project_id/query/ \-d '{"query": {"kind": "HogQLQuery","query": "select properties.$current_url from events where properties.$current_url like '\''%/blog%'\'' limit 100"}}'
This is also useful for querying non-event data like persons, data warehouse, session replay metadata, and more. For example, to get a list of all people with the email
property:
curl \-H 'Content-Type: application/json' \-H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" \<ph_app_host>/api/projects/:project_id/query/ \-d '{"query": {"kind": "HogQLQuery","query": "select properties.email from persons where properties.email is not null"}}'
Every query you run is logged in the query_log
table along with details like duration, read bytes, read rows, and more.
Writing performant queries
When writing custom queries, the burden of performance falls onto you. PostHog handles performance for queries we own (for example, in product analytics insights and experiments, etc.), but because performance depends on how queries are structured and written, we can't optimize them for you. Large data sets particularly require extra careful attention to performance.
Here is some advice for making sure your queries are quick and don't read over too much data (which can increase costs):
1. Use shorter time ranges
You should almost always include a time range in your queries, and the shorter the better. There are a variety of SQL features to help you do this including now()
, INTERVAL
, and dateDiff
. See more about these in our SQL docs.
curl \-H 'Content-Type: application/json' \-H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" \<ph_app_host>/api/projects/:project_id/query/ \-d '{"query": {"kind": "HogQLQuery","query": "SELECT count() FROM events WHERE timestamp >= now() - INTERVAL 7 DAY"}}'
2. Materialize a view for the data you need
The data warehouse enables you to save and materialize views of your data. This means that the view is precomputed, which can significantly improve query performance.
To do this, write your query in the SQL editor, click Materialize, then Save and materialize, and give it a name without spaces (I chose mat_event_count
). You can also schedule to update the view at a specific interval.


Once done, you can query the view like any other table.
curl \-H 'Content-Type: application/json' \-H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" \<ph_app_host>/api/projects/:project_id/query/ \-d '{"query": {"kind": "HogQLQuery","query": "SELECT * FROM mat_event_count"}}'
3. Don't scan the same table multiple times
Reading a large table like events
or persons
more than once in the same query multiplies the work PostHog has to do (more I/O, more CPU, more memory). For example, this query is inefficient:
WITH us_events AS (SELECT *FROM eventsWHERE properties.$geoip_country_code = 'US'),ca_events AS (SELECT *FROM eventsWHERE properties.$geoip_country_code = 'CA')SELECT *FROM us_eventsUNION ALLSELECT *FROM ca_events
Instead, pull the rows you need once, typically in a first CTE, and have every later step reference that CTE like this:
WITH base_events AS ( -- one read onlySELECT event, properties.$geoip_country_code as countryFROM eventsWHERE properties.$geoip_country_code IN ('US', 'CA')),us_events AS (SELECT eventFROM base_eventsWHERE country = 'US'),ca_events AS (SELECT eventFROM base_eventsWHERE country = 'CA')SELECT *FROM us_eventsUNION ALLSELECT *FROM ca_events
4. Other SQL optimizations
Option 1 and 2 make the most difference, but other generic SQL optimizations work too. See our SQL docs for commands, useful functions, and more to help you with this.
Query parameters
Top level request parameters include:
query
(required): Specifies what data to retrieve. This must include akind
property that defines the query type.client_query_id
(optional): A client-provided identifier for tracking the query.refresh
(optional): Controls caching behavior and execution mode (sync vs async).filters_override
(optional): Dashboard-specific filters to apply.variables_override
(optional): Variable overrides for queries that support variables.name
(optional): A name for the query to better identify it in thequery_log
table.
Caching and execution modes
The refresh
parameter controls the execution mode of the query. It can be one of the following values:
blocking
(default): Executes synchronously unless fresh results exist in cacheasync
: Executes asynchronously unless fresh results exist in cacheforce_blocking
: Always executes synchronouslyforce_async
: Always executes asynchronouslyforce_cache
: Only returns cached results (never calculates)lazy_async
: Use extended cache period before asynchronous calculationasync_except_on_cache_miss
: Use cache but execute synchronously on cache miss
Tip: To cancel a running query, send a
DELETE
request to the/api/projects/:project_id/query/:query_id/
endpoint.
Query types
The kind
property in the query
parameter can be one of the following values.
HogQLQuery
: Queries using PostHog's version of SQL.EventsQuery
: Raw event data retrievalTrendsQuery
: Time-series trend analysisFunnelsQuery
: Conversion funnel analysisRetentionQuery
: User retention analysisPathsQuery
: User journey path analysis
Beyond HogQLQuery
, these are mostly used to power PostHog internally and are not useful for you, but you can see the frontend query schema for a complete list and more details.
Response structure
The response format depends on the query type, but all responses include:
results
: The data returned by the queryis_cached
(for cached responses): Indicates the result came from cachetimings
(when available): Performance metrics for the query execution
Cached responses
API queries are cached by default. You can check if a response is cached by checking the is_cached
property. Responses also contain cache-related details like:
cache_key
: A unique identifier for the cached resultcache_target_age
: The timestamp until which the cached result is considered validlast_refresh
: When the data was last computednext_allowed_client_refresh
: The earliest time when a client can request a fresh calculation
Asynchronous queries
For asynchronous queries (like ones with refresh: async
), the initial response includes a query status with its completion status, query ID, start time, and more:
{"query_status": {"id": "2fbd4b19413342a4ad08c307155187bc","team_id": 123,"complete": false}}
You can then poll the status by sending a GET
request to the /api/projects/:project_id/query/:query_id/
endpoint.
curl \-H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" \<ph_app_host>/api/projects/:project_id/query/$QUERY_ID/
Rate limits
API queries on our free plans are limited to:
- 1 query running concurrently
- 10,000 rows
- 1TB read bytes during query processing
Our pay-as-you-go plan lifts this to:
- 3 queries running concurrently
If you need higher limits than these, get in touch with our sales team. We promise they're friendly and technical enough to know what an API is.