JOINs in Elasticsearch? One SQL Statement. Zero Application Code.
SQL-defined materialized views: one CREATE statement in place of a denormalization pipeline.
·4 min read
This is Part 7 of the SoftClient4ES series. Previously: Elasticsearch Queries That Never Break in Production, Stop Rewriting Your Elasticsearch Code Every Version Upgrade, It’s 3 AM. Production Is Down. Your Only Tool Is curl., Elasticsearch Schema Management Was Hell. Then Someone Typed SQL., A 47-Line curl Script to Insert One Document. Seriously., and Connect DBeaver to Elasticsearch. Yes, Really.
The Wall Every Elasticsearch Team Hits
Elasticsearch has no native support for materialized views. No product on the market offers SQL-defined, continuously refreshed materialized views for Elasticsearch — until now.
Every team that uses Elasticsearch for more than simple search eventually hits the same wall:
“How do we JOIN data across indices?”
A fintech company stores orders in one Elasticsearch index and customers in another. Their dashboard needs to show: order amount, customer name, customer city.
Simple in PostgreSQL:
SELECT o.amount, c.name, c.cityFROM orders oJOIN customers c ON o.customer_id = c.idImpossible in Elasticsearch. No JOINs.
So the team builds a “denormalization pipeline”:
- A Kafka consumer listens for order events- For each order, it looks up the customer in another index- It writes an enriched document to a third index- Another consumer listens for customer updates- When a customer changes, it re-enriches all their orders
6 months later:- 2,000 lines of application code- 3+ Kafka topics (inputs, outputs, dead-letter queues - often 5-6 in production)- A race condition that causes stale customer names 1% of the time- A backlog of 500K documents during peak hours- An on-call rotation just for the denormalization pipelineSound familiar?
One SQL Statement
CREATE MATERIALIZED VIEW orders_with_customers_mvREFRESH EVERY 8 SECONDSWITH (delay = '1s', user_latency = '1s')ASSELECT o.id, o.amount, o.status, c.name AS customer_name, c.email AS customer_email, c.city AS customer_city, c.country AS customer_country, c.department.zip_code AS customer_zip, UPPER(c.name) AS customer_name_upperFROM orders AS oJOIN customers AS c ON o.customer_id = c.idWHERE o.status = 'completed';That’s it. No Kafka. No consumer code. No denormalization pipeline. No 2,000 lines of application code. And no additional infrastructure — everything runs on your existing Elasticsearch cluster.
One SQL statement replaces thousands of lines of custom denormalization code.
What Happens Under the Hood
That single CREATE statement triggers a 10-step automated deployment:
Step 1: Alter source schemas → add changelog tracking fieldsStep 2: Create intermediate indices → changelog indices, enriched indices, view indexStep 3: Preload changelogs → copy existing data into changelog indicesStep 4: Create enrich policies → define lookup enrichment from customersStep 5: Create watcher → schedule enrich policy re-executionStep 6: Execute enrich policies → build initial enrich indicesStep 7: Create ingest pipelines → enrichment + computed field processorsStep 8: Create transforms → changelog, enrichment, computed fieldsStep 9: Start transforms → sequentially, with checkpoint waitsStep 10: Save metadata → persist MV definition for managementEach SQL concept maps to Elasticsearch primitives:
| SQL Concept | Elasticsearch Primitive |
|---|---|
| Source tables | Source indices |
| JOIN | Enrich policy + ingest pipeline |
| Computed columns (UPPER, COALESCE, …) | Script processors in ingest pipelines |
| Continuous refresh | Transforms (latest mode) |
| Aggregations (GROUP BY) | Transforms (pivot mode) |
| Auto-refresh on lookup changes | Watcher (re-executes enrich policies) |
The developer writes SQL. The engine orchestrates the Elasticsearch primitives.
Query It Like a Regular Table
Once the transforms complete their first checkpoint, the materialized view is a regular Elasticsearch index:
SELECT * FROM orders_with_customers_mvWHERE customer_name = 'Alice'ORDER BY amount DESCLIMIT 10;Sub-millisecond response. No JOIN at query time. Everything is pre-computed and continuously refreshed.
Aggregations: Pre-Computed Dashboards
Materialized views aren’t limited to JOINs. They can also precompute aggregations:
CREATE MATERIALIZED VIEW revenue_by_city_mvASSELECT c.city, c.country, COUNT(*) AS order_count, SUM(o.amount) AS total_revenue, AVG(o.amount) AS avg_order_value, MAX(o.amount) AS max_orderFROM orders oJOIN customers c ON o.customer_id = c.idWHERE o.status = 'completed'GROUP BY c.city, c.countryHAVING SUM(o.amount) > 10000ORDER BY total_revenue DESC;A pre-aggregated, continuously refreshed summary table — from one SQL statement.
Dashboard queries that used to require application-side denormalization across millions of documents now hit a small, pre-computed index. Response time drops from minutes to milliseconds.
Simple Views (No JOIN)
Not every materialized view needs a JOIN. A simple filter with continuous refresh is just as useful:
CREATE MATERIALIZED VIEW active_orders_mvREFRESH EVERY 30 SECONDSASSELECT id, amount, status, created_atFROM ordersWHERE status = 'active';A continuously refreshed subset of an index. Perfect for dashboards that only care about active data.
Full Lifecycle: From Setup to Teardown
1. Create Source Tables
CREATE TABLE IF NOT EXISTS orders ( id INT NOT NULL, customer_id INT NOT NULL, amount DOUBLE, status KEYWORD DEFAULT 'pending', createdAt TIMESTAMP, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS customers ( id INT NOT NULL, name VARCHAR, email KEYWORD, city KEYWORD, country KEYWORD, department STRUCT FIELDS ( name VARCHAR, zip_code KEYWORD ), PRIMARY KEY (id));2. Load Data
INSERT INTO customers (id, name, email, city, country, department) VALUES (1, 'Alice', 'alice@example.com', 'Paris', 'France', {name = 'Engineering', zip_code = '75001'}), (2, 'Bob', 'bob@example.com', 'Lyon', 'France', {name = 'Marketing', zip_code = '69001'}), (3, 'Chloe', 'chloe@example.com', 'Marseille', 'France', {name = 'Sales', zip_code = '13001'});INSERT INTO orders (id, customer_id, amount, status) VALUES (101, 1, 250.00, 'completed'), (102, 2, 180.50, 'completed'), (103, 1, 420.00, 'completed'), (104, 3, 95.00, 'pending');3. Create the Materialized View
CREATE OR REPLACE MATERIALIZED VIEW orders_with_customers_mvREFRESH EVERY 8 SECONDSWITH (delay = '1s', user_latency = '1s')ASSELECT o.id, o.amount, c.name AS customer_name, c.email, c.city AS customer_city, c.country AS customer_country, c.department.zip_code AS customer_zip, UPPER(c.name) AS customer_name_upperFROM orders AS oJOIN customers AS c ON o.customer_id = c.idWHERE o.status = 'completed';4. Inspect
-- View the schemaDESCRIBE MATERIALIZED VIEW orders_with_customers_mv;
| Field | Type | Null | Key | Default | Comment | Script | Extra ||----------------------|---------|------|-----|---------|---------|--------|-------|| id | INT | yes | | NULL | | | () || amount | DOUBLE | yes | | NULL | | | () || customer_name | VARCHAR | yes | | NULL | | | () || email | KEYWORD | yes | | NULL | | | () || customer_city | KEYWORD | yes | | NULL | | | () || customer_country | KEYWORD | yes | | NULL | | | () || customer_zip | KEYWORD | yes | | NULL | | | () || customer_name_upper | KEYWORD | yes | | NULL | | | () |
-- View the original SQLSHOW CREATE MATERIALIZED VIEW orders_with_customers_mv;
-- Check transform statusSHOW MATERIALIZED VIEW STATUS orders_with_customers_mv;
-- List all materialized viewsSHOW MATERIALIZED VIEWS;5. Query
SELECT * FROM orders_with_customers_mvWHERE customer_name = 'Alice'ORDER BY amount DESC;
| id | amount | customer_name | email | customer_city | customer_country | customer_zip | customer_name_upper ||-----|--------|---------------|-------------------|---------------|------------------|--------------|---------------------|| 103 | 420.00 | Alice | alice@example.com | Paris | France | 75001 | ALICE || 101 | 250.00 | Alice | alice@example.com | Paris | France | 75001 | ALICE |6. Force a Refresh
REFRESH MATERIALIZED VIEW orders_with_customers_mv WITH SCHEDULE NOW;7. Clean Up
DROP MATERIALIZED VIEW IF EXISTS orders_with_customers_mv;This drops all artifacts: transforms, intermediate indices, ingest pipelines, enrich policies, watchers. No orphaned resources.
Deployment Safety: Automatic Rollback
What happens if something goes wrong during the 10-step deployment?
Step 1: Alter source schemas ✅Step 2: Create intermediate indices ✅Step 3: Preload changelogs ✅Step 4: Create enrich policies ✅Step 5: Create watcher ✅Step 6: Execute enrich policies ✅Step 7: Create ingest pipelines ❌ FAILED
→ Automatic rollback initiated→ Steps 6, 5, 4, 3, 2, 1 reversed→ Original state restored→ Error message returned with detailsNo orphaned indices. No half-deployed pipelines. No manual cleanup.
The engine tracks every step and reverses them in order if any step fails. This is the kind of safety net that application-side denormalization code never has.
Refresh Options
Configurable Interval
-- Fast refresh for real-time dashboardsREFRESH EVERY 8 SECONDSWITH (delay = '1s', user_latency = '1s')
-- Moderate refresh for operational viewsREFRESH EVERY 1 MINUTE
-- Slow refresh for analytical viewsREFRESH EVERY 1 HOUROptions
| Option | Description | Example |
|---|---|---|
delay | Delay before processing new data (allows late arrivals) | '5s' |
user_latency | Maximum acceptable query latency for users | '1s' |
CREATE MATERIALIZED VIEW fast_view_mvREFRESH EVERY 5 SECONDSWITH (delay = '2s', user_latency = '500ms')ASSELECT ...Before & After
| Aspect | Application-Side Denormalization | Materialized Views |
|---|---|---|
| Code to write | 1,000-3,000 lines | 1 SQL statement |
| Infrastructure | Kafka + consumers + monitoring | No additional infra (ES-native) |
| Refresh latency | Minutes (consumer lag) | Seconds (configurable) |
| Stale data risk | High (race conditions) | Low (transform-based) |
| Schema changes | Redeploy application | DROP + CREATE |
| Rollback on failure | Manual | Automatic |
| Maintenance burden | Ongoing | Minimal |
| Computed columns | Custom code per field | UPPER(c.name), COALESCE(...) |
| Aggregations | Custom reduce logic | GROUP BY + SUM, AVG, COUNT |
Elasticsearch License Considerations
Materialized views rely on Elasticsearch primitives that have different license requirements:
| Elasticsearch Feature | Required ES License |
|---|---|
| Transforms (continuous data sync) | Free / Basic (ES 7.5+) |
| Enrich Policies (JOIN enrichment) | Free / Basic (ES 7.5+) |
| Watchers (auto-refresh enrich policies) | Platinum / Enterprise / Trial |
Transforms and enrich policies work on free Elasticsearch clusters. The only paid requirement is Watchers, which automate the re-execution of enrich policies when lookup table data changes.
Without a Platinum ES license, use an external scheduler as a workaround:
-- Cron job, Kubernetes CronJob, or Airflow taskEXECUTE ENRICH POLICY orders_with_customers_mv_customers_enrich_policy;
-- Or trigger a full refreshREFRESH MATERIALIZED VIEW orders_with_customers_mv;Editions
Materialized views ship under the Elastic License 2.0 — free to use, sources not public — and are available in the community edition:
| Feature | Community (free) | Pro | Enterprise |
|---|---|---|---|
| Materialized Views | Yes (1 view) | Yes (50 views) | Unlimited |
| JDBC driver | Yes | Yes | Yes |
| Full DDL / DML / DQL | Yes | Yes | Yes |
One materialized view is enough to evaluate the feature, build a proof of concept, or power a small production use case.
Quick Reference
-- CreateCREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS] name [REFRESH EVERY n time_unit] [WITH (delay = 'interval', user_latency = 'interval')] AS SELECT ... [FROM t1 JOIN t2 ON t1.col = t2.col] [WHERE ...] [GROUP BY ... HAVING ...] [ORDER BY ... LIMIT n]
-- Drop (cleans up all artifacts)DROP MATERIALIZED VIEW [IF EXISTS] name;
-- Force refreshREFRESH MATERIALIZED VIEW [IF EXISTS] name [WITH SCHEDULE NOW];
-- InspectDESCRIBE MATERIALIZED VIEW name;SHOW MATERIALIZED VIEW name;SHOW CREATE MATERIALIZED VIEW name;SHOW MATERIALIZED VIEW STATUS name;SHOW MATERIALIZED VIEWS;Resources
- Materialized Views Documentation: Full MV Reference
- DDL Documentation: Full DDL Reference
- REPL Documentation: Full REPL Guide
- GitHub: SoftClient4ES Repository
- Discussions: Ask Questions
- LinkedIn: SoftNetwork
- Website:
softclient4es.dev
How does your team handle cross-index data in Elasticsearch today? If the answer involves Kafka, custom consumers, and thousands of lines of denormalization code — there’s a one-line alternative.
P.S. — Materialized views require Elasticsearch 7.5+ and ship under the Elastic License 2.0 — free to use, sources not public — in the community edition (up to 1 view). They work through the REPL, the JDBC driver, and the Scala API.
This post first appeared on Medium. This is the maintained version — free to read, no account, and corrected as the product moves.
Try it on your own cluster
SoftClient4ES runs SQL — DDL, DML, queries, cross-index JOINs and materialized views — on Elasticsearch 6 through 9.