Skip to content
ExponaBLOG
ELASTICSEARCH & SEARCH ENGINEERING · DAY 022 / 100

Aggregations explained: a practical guide for developers

Analytics and filters from search data.

Expona Admin7 min read

Aggregations explained: a practical guide for developers

When you're running an e-commerce platform or a SaaS analytics dashboard, raw search results alone won't cut it. You need to understand patterns in your data: which products sell most, how revenue breaks down by region, or what user behaviours cluster together. Elasticsearch aggregations are your answer. In this guide, we'll walk through bucket and metric aggregations, show you how to nest them for deeper insights, and demonstrate real patterns from production systems.

Why aggregations matter in production

Aggregations let you compute analytics directly at query time without pulling millions of documents back to your application layer. Instead of fetching every order and grouping them in code, you ask Elasticsearch to bucket them by date, sum the totals, and return a compact result set. For a ride-hailing platform tracking trip metrics, or a travel system breaking down bookings by destination, this approach keeps response times under 100ms even with billions of documents.

The mental model is straightforward: aggregations partition your search results into buckets (groupings) and optionally compute metrics within or across those buckets. Think of it as GROUP BY in SQL, but richer and more flexible.

Bucket aggregations: grouping documents

Bucket aggregations divide your documents into logical groups. Each bucket is a subset of documents that match a specific criterion. The most common types are terms, range, and date_histogram.

Terms aggregation

The terms aggregation groups documents by the unique values of a field. Imagine a product catalogue where you want to see how many items exist in each category. Here's the request:

json
{
  "query": { "match_all": {} },
  "aggs": {
    "by_category": {
      "terms": {
        "field": "category.keyword",
        "size": 20
      }
    }
  }
}

Elasticsearch returns a list of buckets, one for each unique category, ordered by document count (highest first). The size parameter limits the number of buckets returned; setting it to 20 retrieves the top 20 categories. In production, we've used this to rank product categories on homepage dashboards, instantly showing merchants which segments drive engagement.

A critical detail: use .keyword for exact matching on string fields. The text analyzer tokenizes words, making aggregation results unintuitive. For category "Home & Garden", a text field would split it into separate terms.

Range aggregation

The range aggregation partitions documents into buckets based on numeric or date ranges. For a CRM system, you might bucket deals by size:

json
{
  "query": { "match_all": {} },
  "aggs": {
    "deal_size": {
      "range": {
        "field": "amount",
        "ranges": [
          { "to": 5000 },
          { "from": 5000, "to": 25000 },
          { "from": 25000, "to": 100000 },
          { "from": 100000 }
        ]
      }
    }
  }
}

Each range becomes a bucket. This is especially useful for pricing tiers, revenue brackets, or performance bands. The response shows bucket counts, giving you a distribution snapshot in milliseconds.

Date histogram aggregation

For time-series analysis, date_histogram creates buckets at fixed intervals. A trading platform might track order volume by hour:

json
{
  "query": { "match_all": {} },
  "aggs": {
    "orders_per_hour": {
      "date_histogram": {
        "field": "timestamp",
        "interval": "hour"
      }
    }
  }
}

The result contains one bucket per hour, ordered chronologically. Common intervals are minute, hour, day, month, and year. We've used hourly histograms to detect traffic spikes in ride-hailing systems and daily ones to track subscription churn patterns.

Metric aggregations: computing statistics

Metric aggregations compute single numeric values across a set of documents. They answer questions like "What's the average order value?" or "How many distinct users placed orders today?"

Basic metrics: avg, sum, min, max

These are straightforward:

json
{
  "query": { "match_all": {} },
  "aggs": {
    "avg_price": { "avg": { "field": "price" } },
    "total_revenue": { "sum": { "field": "price" } },
    "cheapest": { "min": { "field": "price" } },
    "most_expensive": { "max": { "field": "price" } }
  }
}

In a single request, you get four statistics. This pattern scales well: a dashboard showing product metrics (average rating, total sales, price range) runs one query instead of four.

Cardinality aggregation

Cardinality counts distinct values, useful for unique user counts, distinct items, or unique referral sources:

json
{
  "query": { "match_all": {} },
  "aggs": {
    "unique_users": {
      "cardinality": {
        "field": "user_id.keyword"
      }
    }
  }
}

Cardinality uses HyperLogLog, an approximation algorithm, so for massive datasets it trades precision for speed. In practice, on a dataset of 10 million users, the error margin is typically under 5%, which is acceptable for most dashboards. For exact counts on smaller datasets (under 100k), consider a value_count sub-aggregation instead.

Nested aggregations: buckets inside buckets

Nested aggregations let you group results and then apply further grouping or metrics. This is where aggregations become powerful for real-world analysis.

Example: revenue by category and month

Say you're running an e-commerce platform and need to see how revenue breaks down by product category, and within each category, by month. Here's the structure:

json
{
  "query": { "match_all": {} },
  "aggs": {
    "by_category": {
      "terms": {
        "field": "category.keyword",
        "size": 50
      },
      "aggs": {
        "by_month": {
          "date_histogram": {
            "field": "order_date",
            "interval": "month"
          },
          "aggs": {
            "revenue": {
              "sum": {
                "field": "order_total"
              }
            }
          }
        }
      }
    }
  }
}

This request groups by category first (creating buckets), then within each category bucket, groups by month (creating nested buckets), and computes the revenue sum for each month-category pair. The response is nested JSON, directly mirroring the aggregation structure.

Depth limits and performance

You can nest as deep as needed, but there are practical limits. Each nesting level adds computational cost. A three-level nesting (category → month → payment_method) is normal. Four or five levels should trigger a review: is the question you're asking really best answered through aggregations, or should you materialize this data into a separate analytics table?

Practical example: count products per tag

Let's build a real example for a product tagging system. Each product has a list of tags (e.g., ["organic", "vegan", "gluten-free"]). You want to see which tags are most commonly applied:

json
{
  "query": { "match_all": {} },
  "size": 0,
  "aggs": {
    "by_tag": {
      "terms": {
        "field": "tags.keyword",
        "size": 20,
        "order": { "_count": "desc" }
      }
    }
  }
}

The "size": 0 tells Elasticsearch not to return any documents, only aggregation results. This saves bandwidth when you only care about analytics. The response looks like:

json
{
  "aggregations": {
    "by_tag": {
      "buckets": [
        { "key": "organic", "doc_count": 1250 },
        { "key": "vegan", "doc_count": 890 },
        { "key": "gluten-free", "doc_count": 720 },
        { "key": "non-gmo", "doc_count": 615 }
      ]
    }
  }
}

This drives a tag cloud UI or recommendation logic. If you wanted to filter further—say, only tags applied to products in the "Beverages" category—you'd add a query filter:

json
{
  "query": {
    "term": {
      "category.keyword": "Beverages"
    }
  },
  "aggs": {
    "by_tag": {
      "terms": {
        "field": "tags.keyword",
        "size": 20
      }
    }
  }
}

Aggregations respect the query filter, so you get tag counts only for documents matching the category. This is how filtering and analytics work together.

Common mistakes

  • Aggregating on text fields: Using field: "category" instead of field: "category.keyword" will tokenize and split your data, producing confusing results. Always use .keyword for exact-match aggregations.
  • Forgetting size: 0: If you only need aggregation results, omit document retrieval with "size": 0. Otherwise, you waste network bandwidth and latency fetching documents you won't use.
  • Over-nesting: Each nesting level multiplies computational cost. If you're writing a three-level aggregation, pause and ask: is there a simpler question to ask first? Can you materialize this analysis offline?
  • Ignoring cardinality limits: Aggregating on fields with millions of unique values (like user IDs or UUIDs) creates memory pressure. Use filters to narrow the scope, or reconsider the question.
  • Assuming exact counts from cardinality: HyperLogLog approximation can bite you if exact counts matter for business logic. Test against actual data volumes.

Key takeaways

  • Bucket aggregations (terms, range, date_histogram) group documents into partitions for analysis.
  • Metric aggregations (avg, sum, min, max, cardinality) compute statistics across documents or buckets.
  • Nested aggregations let you group and re-group at multiple levels, answering multi-dimensional questions.
  • Always use .keyword for exact-match aggregations on string fields.
  • Set "size": 0 when you only need aggregation results, not document fetches.
  • Cardinality uses HyperLogLog approximation; acceptable for dashboards but not for exact accounting logic.
  • Filter before aggregating to reduce scope and improve response times.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles