ClickHouse JOIN: Mastering Chained Joins

Introduction

Financial systems analytics demand sophisticated data manipulation, often involving multiple joins to extract valuable insights. In this guide, we will explore how to implement multiple joins, often referred to as chained joins, in ClickHouse, a powerful analytical database. We’ll provide practical, real-life data sets, SQL examples, and offer optimization tips and tricks to ensure optimal query performance.

Understanding Multiple Joins

Multiple joins, or chained joins, occur when you need to combine data from several tables in a single query. In financial systems analytics, this might involve connecting transactions, customer data, account details, and market data to derive comprehensive insights.

Use Case and Data Sets:

Imagine you’re working with a financial institution’s database. You have tables for:

  1. Transactions Table (transactions): Recording every financial transaction.
CREATE TABLE transactions (
transaction_id UUID,
account_id UUID,
transaction_date Date,
transaction_amount Float64
) ENGINE = MergeTree()
ORDER BY transaction_date;

2. Customers Table (customers): Storing customer information.

CREATE TABLE customers (
customer_id UUID,
customer_name String,
customer_type String
) ENGINE = MergeTree()
ORDER BY customer_id;

3. Accounts Table (accounts): Managing account data.

CREATE TABLE accounts (
account_id UUID,
customer_id UUID,
account_type String,
balance Float64
) ENGINE = MergeTree()
ORDER BY account_id;

4. Market Data Table (market_data): Providing real-time market information.

CREATE TABLE market_data (
market_date Date,
stock_symbol String,
market_index Float64
) ENGINE = MergeTree()
ORDER BY market_date;

Implementation in ClickHouse

  1. Join Order: Carefully consider the order of your joins. Start with the smallest dataset or the one that filters the result set most effectively. This reduces the dataset size as you progress.
  2. Optimize for Filtering: Apply filters as early as possible in the join sequence. For example, filter by date or customer type before joining large datasets.
  3. Choose Join Types: ClickHouse supports various join types, including inner, left, and outer joins. Select the appropriate join type for each relationship to retain necessary data.
  4. Indexing: Identify columns used in join conditions and apply indexes to speed up the process.

Optimization Tips and Tricks

  • Use Materialized Views: Precompute complex joins and store them as materialized views, saving computation time during query execution.
  • Partitioning: Partition large tables based on a common column like date, which can significantly reduce query execution times.
  • Limit Results: Implement pagination or result limiting to avoid overwhelming result sets, especially in web applications.
  • Caching: Leverage result caching for frequently used queries to reduce computational load.

SQL Examples

Let’s say you want to find the average transaction amount of high-net-worth customers during market volatility. Here’s a sample SQL query:

SELECT 
AVG(t.transaction_amount) AS avg_transaction_amount
FROM 
transactions AS t
INNER JOIN 
accounts AS a ON t.account_id = a.account_id
INNER JOIN 
customers AS c ON a.customer_id = c.customer_id
INNER JOIN 
market_data AS m ON t.transaction_date = m.market_date
WHERE 
c.customer_type = 'high_net_worth'
AND m.market_index > 0.1

Real-Life Example

Suppose you want to analyze the impact of market data on the trading behavior of high-net-worth customers. By joining the transactions, customer, account, and market data tables in a specific order, you can extract insights into how market fluctuations affect trading patterns.

Nested JOINs: Embedding a JOIN Inside a JOIN

Chained joins line tables up left to right. The other shape we use constantly in financial analytics is the nested JOIN: a subquery on the right-hand side that already contains its own JOIN and, usually, its own GROUP BY. The reason is mechanical. For each JOIN in a chained query, ClickHouse builds a hash table from the right-hand relation and streams the left-hand relation through it. If the right side is a raw fact table, that hash table is the whole fact table. If the right side is a subquery that filters and aggregates first, the hash table is only the rows the outer query actually needs. Nesting is how we shrink the right side before the join ever runs.

Pre-aggregate on the right, then join

Using the same four tables as above, this query joins each account to a per-account transaction summary and a per-customer segment, with both JOIN inputs computed inside subqueries:

-- Nested JOIN: the right side of each JOIN is already filtered and aggregated
SELECT
    a.account_id,
    seg.customer_type,
    tx.tx_count,
    tx.net_amount
FROM accounts AS a
INNER JOIN
(
    SELECT
        account_id,
        count()                 AS tx_count,
        sum(transaction_amount) AS net_amount
    FROM transactions
    WHERE transaction_date >= toDate('2026-01-01')
    GROUP BY account_id
) AS tx ON a.account_id = tx.account_id
INNER JOIN
(
    SELECT
        c.customer_id,
        c.customer_type
    FROM customers AS c
    WHERE c.customer_type = 'high_net_worth'
) AS seg ON a.customer_id = seg.customer_id
ORDER BY tx.net_amount DESC
LIMIT 100;

The WHERE on transaction_date sits inside the subquery on purpose. Placed there, it prunes parts and granules through the transactions primary key before aggregation, and the hash table ClickHouse builds for the first JOIN holds one row per account rather than one row per transaction. Moving that predicate to the outer WHERE would still return the same rows, but the join would have been built against the full table first.

The same query as CTEs

Once a nested JOIN reaches three or four levels, we rewrite it with WITH. ClickHouse inlines each CTE at every reference, so this is a readability change, not an execution-plan change; the pre-aggregation still happens before the chained joins execute.

WITH
    tx AS
    (
        SELECT
            account_id,
            count()                 AS tx_count,
            sum(transaction_amount) AS net_amount
        FROM transactions
        WHERE transaction_date >= toDate('2026-01-01')
        GROUP BY account_id
    ),
    seg AS
    (
        SELECT customer_id, customer_type
        FROM customers
        WHERE customer_type = 'high_net_worth'
    )
SELECT
    a.account_id,
    seg.customer_type,
    tx.tx_count,
    tx.net_amount
FROM accounts AS a
INNER JOIN tx  ON a.account_id  = tx.account_id
INNER JOIN seg ON a.customer_id = seg.customer_id
ORDER BY tx.net_amount DESC
LIMIT 100;

Where PREWHERE helps, and where it does not

PREWHERE applies to the MergeTree table being scanned in that particular SELECT. It reads the filter columns first, drops granules that fail, and only then reads the remaining columns. Inside a nested subquery it is a cheap win when the filter column is small and the payload columns are wide. It does nothing for the JOIN condition itself, and the optimizer already moves simple WHERE predicates into PREWHERE automatically (optimize_move_to_prewhere = 1), so we write it explicitly only when we want to control the order in which columns are read.

Keeping nested JOINs inside memory

Every level of nesting adds a hash table, exactly as every extra table in a run of chained joins does, and they are all live at once. Before we ship a nested JOIN to production we look at three numbers in system.query_log: read_rows, memory_usage, and the JOIN-related counters in ProfileEvents. When the right side is still too large after filtering, the controls are:

  • join_algorithm: hash is fastest while the right side fits in RAM; parallel_hash builds the table on several threads; grace_hash spills to disk in buckets when it does not fit; partial_merge trades CPU for a much smaller footprint when both sides are pre-sorted.
  • max_bytes_in_join and max_rows_in_join with join_overflow_mode = 'break' stop a runaway hash table before it takes the whole node down.
  • Since ClickHouse 26.5, max_bytes_ratio_before_external_join defaults to 0.5, so a hash join that exceeds half the query memory limit spills automatically instead of failing with MEMORY_LIMIT_EXCEEDED. On older releases set it explicitly or pick grace_hash for the heavy joins.
-- Compare the memory profile of two shapes of the same join
SELECT
    query_id,
    read_rows,
    formatReadableSize(memory_usage) AS peak_memory,
    query_duration_ms,
    ProfileEvents['JoinBuildTableRowCount'] AS join_build_rows
FROM system.query_log
WHERE type = 'QueryFinish'
  AND event_date = today()
  AND query ILIKE '%FROM accounts AS a%'
ORDER BY event_time DESC
LIMIT 10;

Mistakes we see in nested and chained joins

  • Joining on columns of different types (UUID to String, UInt64 to Int64). ClickHouse raises NOT_IMPLEMENTED or silently casts; put an explicit CAST in the subquery so the hash keys match.
  • Expecting a secondary index to speed up the JOIN key. There is none. The primary key only helps the scan that feeds the join; the join itself is a hash lookup.
  • Forgetting that a LEFT JOIN with no match fills the right-hand columns with type defaults (0, empty string) rather than NULL, unless join_use_nulls = 1. Downstream sums quietly include those zeros.
  • Nesting for its own sake. If the inner subquery does not filter or aggregate, it is only a chained join with extra parentheses, and the executor builds the same hash tables either way.

Test any change to the order of chained joins or to join_algorithm against a production-sized copy before rolling it out, and keep the previous query text so the rollback is a single deploy.

Conclusion

Multiple joins in ClickHouse are essential for in-depth financial systems analytics. With careful planning, optimisation, and consideration of join order, you can efficiently extract valuable insights from complex datasets. By applying these techniques, you’ll be well-equipped to tackle intricate financial analytics tasks with confidence and precision.

Further Reading:

ClickHouse Performance Pitfalls: 7 Mistakes That Slow Down Your Queries and How to Fix Them

ClickHouse Disaster Recovery Drills: Frequency, Scope, and Reporting

How to Run a Complete ClickHouse Performance Audit in Under 60 Minutes

About Shiv Iyer 262 Articles
Open Source Database Systems Engineer with a deep understanding of Optimizer Internals, Performance Engineering, Scalability and Data SRE. Shiv currently is the Founder, Investor, Board Member and CEO of multiple Database Systems Infrastructure Operations companies in the Transaction Processing Computing and ColumnStores ecosystem. He is also a frequent speaker in open source software conferences globally.