Apache Pinot: A Step-by-Step Guide to Setting Up a Real-Time OLAP Database for Sub-100ms Analytics Dashboards

Database tutorial - IT technology blog
Database tutorial - IT technology blog

When Do You Actually Need Apache Pinot?

Before getting into the installation, let me share a real story. Last year I was responsible for the analytics system of an e-commerce platform. We started with plain MySQL, and the “daily revenue” dashboard query ran in 3–5 seconds — acceptable. But once the data hit 50 million rows, that number jumped to 30–40 seconds. Users were constantly complaining, the PM’s deadline was closing in, and I had to find a solution fast.

I considered migrating the database. I’d been through a 100GB migration from MySQL to PostgreSQL before — three days of planning and one full day of execution. But this time, PostgreSQL wasn’t the answer either, because the core problem was OLAP (Online Analytical Processing), not OLTP. That’s when I found Apache Pinot.

Apache Pinot is a distributed OLAP datastore developed by LinkedIn and open-sourced in 2015. LinkedIn, Uber, and Walmart use it for user-facing analytics — dashboards that end users see directly and that cannot afford more than a second of wait time.

Why is Pinot faster? The architecture is fundamentally different:

  • Columnar storage: reads only the columns needed instead of entire rows, significantly reducing I/O
  • Star-tree index: pre-aggregates data by dimension, making GROUP BY queries 10–100x faster
  • Real-time + batch ingestion: accepts data from Kafka or CSV/JSON/Parquet files
  • Horizontal scaling: add servers without downtime

Need to query 1 billion rows and return results in under 100ms? That’s exactly the problem Pinot was designed to solve.

Installing Apache Pinot

Installing with Docker (Recommended for Dev/Test)

For local dev/test, Docker is much more convenient — no need to install Java separately or configure the classpath. Assuming Docker and Docker Compose are already installed, create a docker-compose.yml file:

version: '3.7'
services:
  zookeeper:
    image: zookeeper:3.5.6
    hostname: zookeeper
    ports:
      - "2181:2181"
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000

  pinot-controller:
    image: apachepinot/pinot:1.2.0
    command: "StartController -zkAddress zookeeper:2181"
    restart: unless-stopped
    ports:
      - "9000:9000"
    depends_on:
      - zookeeper

  pinot-broker:
    image: apachepinot/pinot:1.2.0
    command: "StartBroker -zkAddress zookeeper:2181"
    restart: unless-stopped
    ports:
      - "8099:8099"
    depends_on:
      - pinot-controller

  pinot-server:
    image: apachepinot/pinot:1.2.0
    command: "StartServer -zkAddress zookeeper:2181"
    restart: unless-stopped
    ports:
      - "8098:8098"
    depends_on:
      - pinot-controller
docker-compose up -d

# Check that containers are running
docker-compose ps

Wait about 30–60 seconds for the Controller to fully start. Then open your browser at http://localhost:9000 — you’ll see the Pinot UI.

Installing with Binary (Production)

# Download Pinot 1.2.0
wget https://downloads.apache.org/pinot/apache-pinot-1.2.0/apache-pinot-1.2.0-bin.tar.gz
tar -xvf apache-pinot-1.2.0-bin.tar.gz
cd apache-pinot-1.2.0-bin

# Start ZooKeeper
bin/pinot-admin.sh StartZookeeper -zkPort 2181 &

# Start Controller
bin/pinot-admin.sh StartController \
  -zkAddress localhost:2181 \
  -controllerPort 9000 &

# Start Broker
bin/pinot-admin.sh StartBroker -zkAddress localhost:2181 &

# Start Server
bin/pinot-admin.sh StartServer -zkAddress localhost:2181 &

Detailed Configuration

Pinot needs three things to run: a Schema (data structure), a Table Config (storage and index configuration), and Data Ingestion (how to load data). The examples below use an e-commerce orders dataset — easy to visualize and close to real-world use cases.

Step 1: Create the Schema

The schema defines the data structure with three column types:

  • dimensionFieldSpecs: for filtering/grouping (category, user_id…)
  • metricFieldSpecs: for aggregation (revenue, count…)
  • dateTimeFieldSpecs: the time column — required

Create the file orders_schema.json:

{
  "schemaName": "orders",
  "dimensionFieldSpecs": [
    {"name": "order_id", "dataType": "STRING"},
    {"name": "user_id", "dataType": "STRING"},
    {"name": "product_category", "dataType": "STRING"},
    {"name": "status", "dataType": "STRING"},
    {"name": "payment_method", "dataType": "STRING"}
  ],
  "metricFieldSpecs": [
    {"name": "revenue", "dataType": "DOUBLE"},
    {"name": "quantity", "dataType": "INT"},
    {"name": "discount", "dataType": "DOUBLE"}
  ],
  "dateTimeFieldSpecs": [
    {
      "name": "order_timestamp",
      "dataType": "TIMESTAMP",
      "format": "1:MILLISECONDS:EPOCH",
      "granularity": "1:MILLISECONDS"
    }
  ]
}
curl -X POST \
  "http://localhost:9000/schemas" \
  -H "Content-Type: application/json" \
  -d @orders_schema.json

Step 2: Create the Table Config with Indexes

Index configuration is where Pinot truly sets itself apart from relational databases. Same data, same query — but the right index gives you 50ms, the wrong one gives you 5 seconds. Create the file orders_table.json:

{
  "tableName": "orders",
  "tableType": "OFFLINE",
  "segmentsConfig": {
    "timeColumnName": "order_timestamp",
    "timeType": "MILLISECONDS",
    "replication": "1",
    "schemaName": "orders"
  },
  "tableIndexConfig": {
    "loadMode": "MMAP",
    "invertedIndexColumns": ["product_category", "status", "payment_method"],
    "starTreeIndexConfigs": [
      {
        "dimensionsSplitOrder": ["product_category", "status"],
        "skipStarNodeCreationForDimensions": [],
        "functionColumnPairs": ["SUM__revenue", "COUNT__*"],
        "maxLeafRecords": 10000
      }
    ],
    "rangeIndexColumns": ["revenue", "order_timestamp"]
  },
  "tenants": {
    "broker": "DefaultTenant",
    "server": "DefaultTenant"
  },
  "metadata": {}
}

The three most commonly used index types, each serving a different query pattern:

  • invertedIndexColumns: lightning-fast exact match lookups — WHERE status = 'COMPLETED' runs in O(1)
  • starTreeIndexConfigs: pre-aggregates by dimension upfront, making GROUP BY queries 10–100x faster than a full scan
  • rangeIndexColumns: fast range queries — WHERE revenue BETWEEN 500000 AND 2000000
curl -X POST \
  "http://localhost:9000/tables" \
  -H "Content-Type: application/json" \
  -d @orders_table.json

Step 3: Load Data (Batch Ingestion)

Create a sample CSV file sample_orders.csv:

order_id,user_id,product_category,status,payment_method,revenue,quantity,discount,order_timestamp
ORD001,USER100,Electronics,COMPLETED,CREDIT_CARD,1500000,1,0,1720310400000
ORD002,USER101,Fashion,COMPLETED,BANK_TRANSFER,350000,2,50000,1720310460000
ORD003,USER102,Electronics,PENDING,MOMO,2200000,1,100000,1720310520000
ORD004,USER103,Food,COMPLETED,CASH,85000,3,0,1720310580000

Create the ingestion job spec ingestion_job.yaml:

executionFrameworkSpec:
  name: 'standalone'
  segmentGenerationJobRunnerClassName: 'org.apache.pinot.plugin.ingestion.batch.standalone.SegmentGenerationJobRunner'
  segmentTarPushJobRunnerClassName: 'org.apache.pinot.plugin.ingestion.batch.standalone.SegmentTarPushJobRunner'

jobType: SegmentCreationAndTarPush

inputDirURI: '/tmp/pinot-data/input'
outputDirURI: '/tmp/pinot-data/output'
overwriteOutput: true

pinotFSSpecs:
  - scheme: file
    className: org.apache.pinot.spi.filesystem.LocalPinotFS

recordReaderSpec:
  dataFormat: 'csv'
  className: 'org.apache.pinot.plugin.inputformat.csv.CSVRecordReader'
  configClassName: 'org.apache.pinot.plugin.inputformat.csv.CSVRecordReaderConfig'

tableSpec:
  tableName: 'orders'
  schemaURI: 'http://localhost:9000/schemas/orders'
  tableConfigURI: 'http://localhost:9000/tables/orders'

pinotClusterSpecs:
  - controllerURI: 'http://localhost:9000'
# Copy CSV to the correct directory
mkdir -p /tmp/pinot-data/input
cp sample_orders.csv /tmp/pinot-data/input/

# Run ingestion
bin/pinot-admin.sh LaunchDataIngestionJob \
  -jobSpecFile ingestion_job.yaml

Testing and Monitoring

Running Test SQL Queries

Pinot supports standard SQL. Open the Pinot UI at http://localhost:9000, go to the Query Console tab, and try:

-- Revenue by category
SELECT
  product_category,
  SUM(revenue)   AS total_revenue,
  COUNT(*)       AS order_count,
  AVG(revenue)   AS avg_order_value
FROM orders
WHERE status = 'COMPLETED'
GROUP BY product_category
ORDER BY total_revenue DESC
LIMIT 10

Or query via the REST API:

curl -X POST \
  "http://localhost:8099/query/sql" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT product_category, SUM(revenue) as total FROM orders GROUP BY product_category LIMIT 10"
  }'

The response includes a timeUsedMs field — this is the actual query time. With a properly configured star-tree index, this number typically stays under 50ms, even with millions of rows.

Monitoring with Python

import requests

def query_pinot(sql: str, broker_url: str = "http://localhost:8099") -> dict:
    response = requests.post(
        f"{broker_url}/query/sql",
        headers={"Content-Type": "application/json"},
        json={"sql": sql},
        timeout=10
    )
    result = response.json()

    time_ms = result.get("timeUsedMs", 0)
    rows = result.get("resultTable", {}).get("rows", [])
    print(f"Query time: {time_ms}ms | Rows returned: {len(rows)}")

    return result

# Benchmark with real data
result = query_pinot("""
    SELECT
      status,
      COUNT(*) AS cnt,
      SUM(revenue) AS total_revenue
    FROM orders
    GROUP BY status
    ORDER BY cnt DESC
""")

# Print results
columns = result["resultTable"]["dataSchema"]["columnNames"]
for row in result["resultTable"]["rows"]:
    print(dict(zip(columns, row)))

Checking Cluster Health

# Health check — returns "OK" if the cluster is healthy
curl http://localhost:9000/health

# List all created tables
curl http://localhost:9000/tables

# Segment info for the orders table
curl http://localhost:9000/segments/orders

# Broker metrics (Prometheus format)
curl http://localhost:8099/metrics | grep pinot_broker

If you run into errors, check the logs for debugging:

# Docker
docker logs pinot-controller --tail 50
docker logs pinot-server --tail 50

# Binary
tail -f logs/pinot-controller.log
tail -f logs/pinot-server.log

The most common mistake for newcomers is forgetting to configure the star-tree index. Without it, Pinot still runs — but query times spike as data grows. Seeing slower-than-expected performance? Review your tableIndexConfig and make sure you have the right dimension/metric pairs in functionColumnPairs.

MySQL and PostgreSQL excel at OLTP — transactions, updates, and deletes on individual rows. Pinot excels at OLAP — fast reads over large datasets without worrying about write throughput. Different problems, different tools. With the setup above, you can benchmark on your own data right away — on the same 50 million rows, the difference between MySQL and Pinot typically goes from 30 seconds down to under 1 second.

Share: