How do Loki and Elasticsearch store and index logs?
Loki and Elasticsearch make opposite bets about when you pay for search. Loki pays at query time. Elasticsearch pays at ingest.
Loki: index the labels, scan the content
Loki indexes only a small set of labels per stream, such as service, namespace and env. The log lines themselves are not indexed.
- Distributor. Receives pushed logs, validates them, and hashes each stream's label set. It uses that hash to forward the stream to a set of ingesters for replication.
- Ingester. Buffers lines per stream into chunks and compresses them. It flushes the chunks to object storage, along with index entries that map label sets and time ranges to chunk references.
- Querier. Uses the index to find chunks that match the label selector and time range. It then fetches and decompresses those chunks and filters lines by brute force. It also asks ingesters for recent data that has not been flushed yet.
- Compactor. Merges index files and applies retention and deletes.
Ingest is cheap and storage is compact. The cost of a query depends on how many bytes it has to scan.
Elasticsearch: index everything up front
Each log event is a JSON document. A node routes the document to a primary shard, where it is analyzed into an inverted index (term to document) and columnar doc values for aggregations. The shard then copies the document to its replica shards.
Shards write immutable segments. These segments are periodically refreshed so they become searchable, and merged in the background. At query time, a coordinating node fans the request out to every relevant shard and merges the results.
Lookups are fast. The cost is CPU, memory and disk spent on indexing every event.
LOKI: small label index at ingest, chunk scan at query time
push +-------------+ hash +-----------+ flush +------------------+
----->| Distributor |-------->| Ingesters |-------->| Object storage |
+-------------+ labels | buffer + | | compressed chunks|
| compress | | + label index |
+-----------+ +------------------+
^ | ^
| unflushed fetch | |
query | lines chunks v |
+-----------------------------------------------------+ +-----------+
----->| Querier: labels+time -> chunk refs -> scan + filter | | Compactor |
+-----------------------------------------------------+ | merge idx |
| retention |
| + deletes |
+-----------+
ELASTICSEARCH: inverted index built at ingest, fast lookups at query
JSON +-------------+ route +----------------------+ copy +-----------------+
doc ->| Any node |------>| Primary shard |----->| Replica shard(s)|
| routes doc | | analyze -> inverted | | same docs |
+-------------+ | index + doc values | +-----------------+
| immutable segments: |
| refresh, then merge |
+----------------------+
^
query | fan out, merge results
+-------------------------------------------+
----->| Coordinating node -> all relevant shards |
+-------------------------------------------+
Loki writes compressed chunks plus a small label index to object storage and scans chunks at query time, while Elasticsearch builds an inverted index and doc values in sharded segments at ingest so queries only fan out and merge.
Which is faster to query, Loki or Elasticsearch?
Query speed depends on the shape of the query. Loki does almost no work before reading data: the label selector picks streams via the index, then queriers decompress and scan every chunk in the time range. Speed tracks bytes scanned. A tight selector over the last 30 minutes is fast. A selector matching every service over seven days means decompressing and line-filtering terabytes, and adding queriers only parallelises that brute force. Elasticsearch pays its cost at ingest, so a term lookup hits the inverted index and returns quickly regardless of range. Its weak spot is aggregation. A terms aggregation on a high-cardinality field such as user_id, or a request for a large result set, builds in-memory structures on every shard, which consumes heap and CPU and can trip circuit breakers.
| Query shape | Loki | Elasticsearch |
|---|---|---|
| Label-scoped incident query (one service, last hour) | Fast; small scan | Fast |
| Full-text search across all services, multi-day | Slow; scans everything | Fast if the field is analysed |
| Trace ID lookup without a label | Slow; brute-force line filter | Fast; single term lookup |
| Error-rate aggregation over time | Fast when label-scoped | Fast on keyword fields; heavy at high cardinality |
As an example, here is the per-5-minute error rate for one service in each system. In LogQL: sum(count_over_time({service="api"} | json | level="error" [5m])). In Elasticsearch query DSL, use a bool filter with term clauses on service and level plus a range on @timestamp. Add a date_histogram aggregation with fixed_interval: "5m" and set size: 0 so no documents are returned.
#!/usr/bin/env bash
# Same task in both systems: per-5-minute error count for one service over the last hour.
set -euo pipefail
LOKI_URL="" # placeholder host
ES_URL="" # placeholder host
# Loki: the label selector {service="api"} narrows the stream set first;
# everything after it (json parse, level filter) runs on scanned lines.
# A step of 300s returns one point per 5-minute window.
curl -sG "$LOKI_URL/loki/api/v1/query_range" \
--data-urlencode 'query=sum(count_over_time({service="api"} | json | level="error" [5m]))' \
--data-urlencode "start=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)" \
--data-urlencode "end=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--data-urlencode 'step=300'
# Elasticsearch: term filters hit the inverted index on keyword fields;
# size 0 skips returning documents so only the histogram buckets come back.
curl -s -X POST "$ES_URL/logs-*/_search" \
-H 'Content-Type: application/json' \
-d '{
"size": 0,
"query": {
"bool": {
"filter": [
{ "term": { "service": "api" } },
{ "term": { "level": "error" } },
{ "range": { "@timestamp": { "gte": "now-1h", "lte": "now" } } }
]
}
},
"aggs": {
"errors_per_5m": {
"date_histogram": { "field": "@timestamp", "fixed_interval": "5m" }
}
}
}'
The same per-5-minute error count for the api service over the last hour, run once against Loki's query_range HTTP API with LogQL and once against Elasticsearch's _search endpoint with a filtered date_histogram aggregation.
How do you design labels in Loki and mappings in Elasticsearch?
Loki: few labels, rich log lines
Every unique combination of label values creates a separate stream with its own chunks and index entries. Keep labels to bounded dimensions such as service, env, cluster and level. Request IDs, user IDs and trace IDs belong in the log line. You narrow by labels first, then filter the content: {service="checkout", env="prod"} |= "timeout" | json | trace_id="abc123". The line filter is cheap and runs before the json parser, so fewer lines reach the more expensive parsing step.
Promoting user_id to a label creates the high-cardinality trap. Streams multiply, chunks stay small and flush often, the index grows, and queries fan out across thousands of tiny streams. Fixing it later is costly. You have to change the shipping pipeline, and every dashboard, alert and saved query that selects on the old label has to be rewritten.
Elasticsearch: explicit mappings, bounded indices
Dynamic mapping guesses field types from the first document it sees. That often leaves a field mapped to the wrong type, and once a field is mapped, you cannot change its type without reindexing. Define an index template that maps service, level and trace_id as keyword for exact filtering and aggregations, and maps message as text for full-text search.
Set dynamic to false or strict so unbounded JSON keys, such as user-supplied maps, don't cause a mapping explosion.
Pair the template with an ILM policy that rolls over on size or age. This keeps shards reasonably sized and the shard count manageable as data ages.
#!/usr/bin/env bash
set -euo pipefail
ES="" # placeholder cluster address
# ILM: roll over on size or age so shards stay bounded; delete old data.
curl -sS -X PUT "$ES/_ilm/policy/logs-backend" \
-H 'Content-Type: application/json' -d '{
"policy": { "phases": {
"hot": { "actions": { "rollover": { "max_primary_shard_size": "50gb", "max_age": "1d" } } },
"delete": { "min_age": "30d", "actions": { "delete": {} } }
} }
}'
# Index template: explicit mappings, no guessing from the first document.
curl -sS -X PUT "$ES/_index_template/logs-backend" \
-H 'Content-Type: application/json' -d '{
"index_patterns": ["logs-backend-*"],
"data_stream": {},
"priority": 200,
"template": {
"settings": { "index.lifecycle.name": "logs-backend" },
"mappings": {
"dynamic": false,
"properties": {
"@timestamp": { "type": "date" },
"service": { "type": "keyword" },
"env": { "type": "keyword" },
"level": { "type": "keyword" },
"trace_id": { "type": "keyword" },
"message": { "type": "text" }
}
}
}
}'
# dynamic=false: unknown keys (e.g. user-supplied maps) stay in _source but
# add no new fields to the mapping, so they cannot cause a mapping explosion.
# keyword = exact filters and aggregations; text = full-text search.
# priority 200 places this template above the built-in logs-*-* template (priority 100).
Creates an ILM policy that rolls over on shard size or age and a data stream index template with explicit keyword and text mappings and dynamic mapping disabled.
How much ops effort does each logging stack need?
That kind of upfront discipline is most of what running Elasticsearch well involves. A self-hosted cluster needs capacity planning across several linked variables: JVM heap per node, disk per data tier, shard count and size, replica count, and dedicated node roles such as master, data, and coordinating. Major-version upgrades need rolling restarts and compatibility checks, so they are planned work, not routine maintenance. The common failure modes all come back to those variables:
- Oversharding. Every shard uses heap and cluster-state overhead, so thousands of small daily indices slow down master operations.
- Heap pressure. Large aggregations or high field counts cause long GC pauses, and nodes drop out of the cluster.
- Mapping explosions. Unbounded dynamic fields grow the cluster state until updates to it stall.
- Cluster instability. Misconfigured master-eligible nodes or discovery settings cause elections to flap.
- Disk watermarks. When a traffic spike pushes a node past the flood-stage watermark, Elasticsearch puts a write block on its indices. Ingestion stops at the moment you most need the logs.
Loki removes most disk and shard management because chunks and indexes live in object storage. It is not zero-ops, though. Ingesters need memory sizing and replication. Queriers, the query frontend, the compactor, and the caches each need sizing and their own monitoring. At higher volumes, query tuning becomes a separate workstream: split intervals, parallelism, per-tenant limits, and cache hit rates.
| Concern | Elasticsearch (self-hosted) | Loki (self-hosted) |
|---|---|---|
| Primary sizing unit | Heap, disk, shards | Ingester memory, querier count, caches |
| Storage management | Local disks, watermarks, ILM | Object storage, compactor retention |
| Typical outage trigger | Disk full, heap/GC, cluster state | Ingester OOM, query overload |
| Tuning focus | Shards, mappings | Query limits, parallelism, caching |
Managed offerings take on most of this cluster work. In exchange, they shift cost toward ingestion and retention pricing and limit how much you can tune. The comparison above applies to running either stack yourself.
What does log storage cost with Loki vs Elasticsearch?
If you run both stacks yourself, the cost difference comes mostly from where the bytes live and how many times they are stored.
Illustrative estimate (assumed numbers, not a benchmark). Assumed inputs: 100 GB/day raw ingest, 30-day retention, one Elasticsearch replica. Elasticsearch stores compressed source at 0.3× raw, and its indexes add another 100% on top. Loki chunks compress to 0.15× raw, and its label index is small enough to ignore.
- Elasticsearch disk = daily × retention × compression × (1 + index overhead) × (1 + replicas)
- Loki storage = daily × retention × compression
| Factor | Elasticsearch | Loki |
|---|---|---|
| Stored footprint | 100 × 30 × 0.3 × 2 × 2 = 3,600 GB | 100 × 30 × 0.15 = 450 GB |
| Storage medium | Hot-tier block storage, usually SSD | Object storage |
| Redundancy | Replicas multiply disk and node count | Handled by the object store |
| Memory driver | Heap and page cache sized to hot indexed data | Ingester buffers and query concurrency |
| Main variable cost | Nodes held for retention | Querier compute per byte scanned |
Elasticsearch's replicas copy the index structures as well as the logs, so every extra day of hot retention needs more nodes, more disk and more RAM. Loki's storage cost is cheap per GB. Its spend instead tracks query load: broad searches over long time ranges pull and decompress many chunks.
The inputs that move the result most are index overhead, which depends on your mappings, and retention length. After those come your query patterns: frequent wide scans can erode Loki's storage advantage. Measure compression on your own log samples rather than borrowing these ratios.
When should you choose Loki, and when should you avoid it?
Start with the queries you actually ran. Pull the last few incident timelines and any audit or security requests, and sort each query by shape. Did it begin with a known service, namespace, or pod and a time window? Or did it search for a string across everything? That split predicts fit better than any feature list, because Loki is fast only when labels shrink the set of chunks it has to scan.
| Factor | Favors Loki | Favors Elasticsearch |
|---|---|---|
| Typical query | Scoped to a service and time window, then grep | Broad full-text search across all sources |
| Analytics | Counts and rates derived from log lines | Aggregations on many structured fields |
| Existing stack | Grafana and Prometheus in use, so labels can match | Kibana-centric workflows already in place |
| Team capacity | Small team, little appetite for stateful clusters | People who can tune shards, heap, and mappings |
| Retention | Long, cost-sensitive, object storage | Shorter hot retention, or a budget for tiers |
| Compliance | Rare, scoped lookups | Frequent investigations across unknown fields |
Avoid Loki if analysts regularly hunt for an IP or token across every source, or if they need to pivot on dozens of fields. Without an index on that content, each search becomes a brute-force scan.
Avoid Elasticsearch if a small team mostly runs label-scoped incident queries and must keep logs for a long time. In that case you pay to index content that nobody searches.
Managed versions of either system remove most of the cluster operations work but not the data-model trade-offs. Many teams route application logs to Loki and send security and audit logs to Elasticsearch. That costs two pipelines, but each log class gets the access pattern it needs.
How do you evaluate Loki vs Elasticsearch before committing?
A short proof of concept costs far less than a migration, so run one before you commit. Base it on your own data, not on published comparisons. Those comparisons used someone else's log shape, someone else's label cardinality and someone else's queries.
Replay real traffic
Take a representative slice of production logs, ideally a few days that include a busy period and an incident. Replay it into both systems at the original rate. Synthetic logs hide the things that hurt in production: messy multiline stack traces, unexpected field types, and high-cardinality values such as request IDs.
Run the queries you actually use
Pull your team's top queries from saved searches, dashboards and alert rules. Run each one over the time ranges people really use, such as the last 15 minutes during an incident or seven days for a postmortem. A query that runs fast over one hour can behave very differently over a week. That gap is where the two systems tend to diverge.
Measure four things
- Ingest lag: the time from when a line is written to when it can be searched.
- Query latency: p50 and p95, measured with cold caches as well as warm ones.
- Resource usage: CPU, memory and disk for every component, including the ones that only run in the background.
- Storage growth: bytes per day per ingested gigabyte, measured after compression and retention have taken effect.
Price the exit
Switching later is a project, not a config change. LogQL and Elasticsearch queries don't translate into each other. Dashboards and alert rules have to be rebuilt by hand. Parsing pipelines also live in different places, because Loki usually extracts fields at query time while Elasticsearch parses them at ingest. Every week the wrong choice stays in production adds to that rewrite, so a few weeks of testing up front is the cheaper path.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to loki vs elasticsearch, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.