← All posts
SQL for Elasticsearch · Part 7

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.city
FROM orders o
JOIN customers c ON o.customer_id = c.id

Impossible 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 pipeline

Sound familiar?

One SQL Statement

CREATE MATERIALIZED VIEW orders_with_customers_mv
REFRESH EVERY 8 SECONDS
WITH (delay = '1s', user_latency = '1s')
AS
SELECT
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_upper
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.id
WHERE 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 fields
Step 2: Create intermediate indices → changelog indices, enriched indices, view index
Step 3: Preload changelogs → copy existing data into changelog indices
Step 4: Create enrich policies → define lookup enrichment from customers
Step 5: Create watcher → schedule enrich policy re-execution
Step 6: Execute enrich policies → build initial enrich indices
Step 7: Create ingest pipelines → enrichment + computed field processors
Step 8: Create transforms → changelog, enrichment, computed fields
Step 9: Start transforms → sequentially, with checkpoint waits
Step 10: Save metadata → persist MV definition for management

Each SQL concept maps to Elasticsearch primitives:

SQL ConceptElasticsearch Primitive
Source tablesSource indices
JOINEnrich policy + ingest pipeline
Computed columns (UPPER, COALESCE, …)Script processors in ingest pipelines
Continuous refreshTransforms (latest mode)
Aggregations (GROUP BY)Transforms (pivot mode)
Auto-refresh on lookup changesWatcher (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_mv
WHERE customer_name = 'Alice'
ORDER BY amount DESC
LIMIT 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_mv
AS
SELECT
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_order
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'completed'
GROUP BY c.city, c.country
HAVING SUM(o.amount) > 10000
ORDER 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_mv
REFRESH EVERY 30 SECONDS
AS
SELECT id, amount, status, created_at
FROM orders
WHERE 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_mv
REFRESH EVERY 8 SECONDS
WITH (delay = '1s', user_latency = '1s')
AS
SELECT
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_upper
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.id
WHERE o.status = 'completed';

4. Inspect

-- View the schema
DESCRIBE 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 SQL
SHOW CREATE MATERIALIZED VIEW orders_with_customers_mv;
-- Check transform status
SHOW MATERIALIZED VIEW STATUS orders_with_customers_mv;
-- List all materialized views
SHOW MATERIALIZED VIEWS;

5. Query

SELECT * FROM orders_with_customers_mv
WHERE 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 details

No 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 dashboards
REFRESH EVERY 8 SECONDS
WITH (delay = '1s', user_latency = '1s')
-- Moderate refresh for operational views
REFRESH EVERY 1 MINUTE
-- Slow refresh for analytical views
REFRESH EVERY 1 HOUR

Options

OptionDescriptionExample
delayDelay before processing new data (allows late arrivals)'5s'
user_latencyMaximum acceptable query latency for users'1s'
CREATE MATERIALIZED VIEW fast_view_mv
REFRESH EVERY 5 SECONDS
WITH (delay = '2s', user_latency = '500ms')
AS
SELECT ...

Before & After

AspectApplication-Side DenormalizationMaterialized Views
Code to write1,000-3,000 lines1 SQL statement
InfrastructureKafka + consumers + monitoringNo additional infra (ES-native)
Refresh latencyMinutes (consumer lag)Seconds (configurable)
Stale data riskHigh (race conditions)Low (transform-based)
Schema changesRedeploy applicationDROP + CREATE
Rollback on failureManualAutomatic
Maintenance burdenOngoingMinimal
Computed columnsCustom code per fieldUPPER(c.name), COALESCE(...)
AggregationsCustom reduce logicGROUP BY + SUM, AVG, COUNT

Elasticsearch License Considerations

Materialized views rely on Elasticsearch primitives that have different license requirements:

Elasticsearch FeatureRequired 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 task
EXECUTE ENRICH POLICY orders_with_customers_mv_customers_enrich_policy;
-- Or trigger a full refresh
REFRESH 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:

FeatureCommunity (free)ProEnterprise
Materialized ViewsYes (1 view)Yes (50 views)Unlimited
JDBC driverYesYesYes
Full DDL / DML / DQLYesYesYes

One materialized view is enough to evaluate the feature, build a proof of concept, or power a small production use case.

Quick Reference

-- Create
CREATE [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 refresh
REFRESH MATERIALIZED VIEW [IF EXISTS] name [WITH SCHEDULE NOW];
-- Inspect
DESCRIBE MATERIALIZED VIEW name;
SHOW MATERIALIZED VIEW name;
SHOW CREATE MATERIALIZED VIEW name;
SHOW MATERIALIZED VIEW STATUS name;
SHOW MATERIALIZED VIEWS;

Resources

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.