GitHub

title API queries
sidebarTitle Overview
sidebar Docs
showTitle true

import { CalloutBox } from 'components/Docs/CalloutBox'

API queries enable you to query your data in PostHog. This is useful for:

  • Building embedded analytics.
  • Pulling aggregated PostHog data into your own or other apps.

The /query endpoint is intended for ad-hoc analytics and embedded use cases. It is not a supported export mechanism.

  • Bulk or recurring exports of events, persons, or query_log are not supported over /query. Use batch exports for ETL, data warehouse syncs, and any integration that pulls more than a few thousand rows on a schedule.
  • Third-party connectors must use batch exports, not /query. Connectors built on /query are not supported and will be rate-limited or rejected.
  • OFFSET pagination is not supported for programmatic requests (personal API keys, OAuth tokens, and similar). It currently returns HTTP 400 for personal API keys, and we may reject it for other authentication methods at any time. Use keyset pagination on timestamp instead (see below).
  • We reserve the right to rate-limit, restrict, or reject queries that look like exports, including without prior notice. Pipelines built on /query may break at any time.
  • Use real-time destinations for sending data to Slack, webhooks, etc.
  • Use materialized views for expensive recurring aggregations. You can query these through SQL and get faster results.

Prerequisites

Using API queries requires:

  1. A PostHog project and its project ID which you can get from your project settings.
  2. 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.

By default, API queries return up to 100 rows. If you specify your own LIMIT, you can return up to 50k rows per query.

For paginating beyond a single query, you must use keyset pagination on timestamp. OFFSET is not supported for programmatic requests and is currently rejected with HTTP 400 for personal API keys. For exporting larger volumes, use batch exports/query is not a supported export path.

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"
        },
        "name": "get 100 blog urls"
      }'
import requests
import json
url = "<ph_app_host>/api/projects/{project_id}/query/"
headers = {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer {POSTHOG_PERSONAL_API_KEY}'
}
payload = {
    "query": {
        "kind": "HogQLQuery",
        "query": "select properties.$current_url from events where properties.$current_url like '%/blog%' limit 100"
    },
    "name": "get 100 blog urls"
}
response = requests.post(url, headers=headers, data=json.dumps(payload))
print(response.json())
import fetch from "node-fetch";
async function createQuery() {
  const url = "<ph_app_host>/api/projects/:project_id/query/";
  const headers = {
    "Content-Type": "application/json",
    "Authorization": "Bearer {POSTHOG_PERSONAL_API_KEY}"
  };
  const payload = {
    "query": {
      "kind": "HogQLQuery",
      "query": "select properties.$current_url from events where properties.$current_url like '%/blog%' limit 100"
    },
    "name": "get 100 blog urls"
  }
  const response = await fetch(url, {
    method: "POST",
    headers: headers,
    body: JSON.stringify(payload),
  });
  const data = await response.json();
  console.log(data);
}
createQuery()

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"
        },
        "name": "get user emails"
      }'
import requests
import json
url = "<ph_app_host>/api/projects/{project_id}/query/"
headers = {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer {POSTHOG_PERSONAL_API_KEY}'
}
payload = {
    "query": {
        "kind": "HogQLQuery",
        "query": "select properties.email from persons where properties.email is not null"
    },
    "name": "get user emails"
}
response = requests.post(url, headers=headers, data=json.dumps(payload))
print(response.json())
import fetch from "node-fetch";
async function createQuery() {
  const url = "<ph_app_host>/api/projects/:project_id/query/";
  const headers = {
    "Content-Type": "application/json",
    "Authorization": "Bearer {POSTHOG_PERSONAL_API_KEY}"
  };
  const payload = {
    "query": {
      "kind": "HogQLQuery",
      "query": "select properties.email from persons where properties.email is not null"
    },
    "name": "get user emails"
  }
  const response = await fetch(url, {
    method: "POST",
    headers: headers,
    body: JSON.stringify(payload),
  });
  const data = await response.json();
  console.log(data);
}
createQuery()

Every query you run is logged in the query_log table along with details like duration, read bytes, read rows, and more. The name parameter you provide appears in this log, making it easier to identify and analyze your queries.

Writing performant queries

import OptimalQueries from '../_snippets/optimal-queries.mdx'

Query parameters

Top level request parameters include:

  • query (required): Specifies what data to retrieve. This must include a kind 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 descriptive name for the query to better identify it in the query_log table. We strongly recommend providing meaningful names for easier debugging and performance analysis.

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 cache
  • async: Executes asynchronously unless fresh results exist in cache
  • force_blocking: Always executes synchronously
  • force_async: Always executes asynchronously
  • force_cache: Only returns cached results (never calculates)
  • lazy_async: Use extended cache period before asynchronous calculation
  • async_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 retrieval
  • TrendsQuery: Time-series trend analysis
  • FunnelsQuery: Conversion funnel analysis
  • RetentionQuery: User retention analysis
  • PathsQuery: 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 query
  • is_cached (for cached responses): Indicates the result came from cache
  • timings (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 result
  • cache_target_age: The timestamp until which the cached result is considered valid
  • last_refresh: When the data was last computed
  • next_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 are limited at the project-level to:

  • 2400 requests per hour
  • 240 requests per minute
  • 3 queries running concurrently
  • 60 threads per query
  • 10 seconds of max execution time
    • applies to query execution time, not HTTP request duration

At this time, we are not offering higher limits than these, but you may wish to try our endpoints product, which offers query customization and higher limits. Alternatively, you may be able to use our batch exports product to pull the data that you need from our events or persons tables on a faster cadence.

If the project's concurrency quota is exhausted, we put the query in queue and wait. The query may wait up to 30 seconds in a queue before executing, being canceled, or timing out.

Some customers haven't been migrated to the above limit and are on an old limit of 120 queries/hour.

Free plan data limit

Organizations without a paid plan can read up to 50 TB of data per calendar month across all API queries. Past that, queries return a 402 response with the code api_queries_quota_exceeded and a message that shows your usage and when the allowance resets (the start of the next month, UTC).

Cached results still work while you are over the limit. Queries made in the PostHog app are not affected.

To remove the limit, subscribe to a paid plan. PostHog is pay-as-you-go with no monthly minimum, and access is restored within moments of subscribing. Trials also lift the limit while active.

Further reading

Read the original on github.com ↗