--- title: "Joins" sidebarTitle: "Joins" description: "Combining rows from more than one table, including across namespaces." icon: link keywords: ["join", "inner", "left", "outer", "cross", "namespace"] availability: embedded: unavailable enterprise: available --- Use SQL joins to combine rows from two or more tables. ## The join types {#types} ```sql SELECT d.title, c.name FROM documents AS d JOIN categories AS c ON c.id = d.category_id ``` | | What it keeps | | --- | --- | | `[INNER] JOIN` | rows matching on both sides | | `LEFT [OUTER] JOIN` | every left row, nulls where the right has no match | | `RIGHT [OUTER] JOIN` | every right row | | `FULL [OUTER] JOIN` | every row from both | | `CROSS JOIN` | every combination (the product of the two) | `USING (col)` is shorthand for an equality join on a shared column name, and avoids repeating it in the select list. ## Across namespaces {#namespaces} Tables in different [namespaces](/management/namespace) join by their dotted paths. The catalog is one catalog, so there is nothing to federate: ```sql SELECT c.id, u.email FROM prod.events.clicks AS c JOIN prod.reference.users AS u ON u.id = c.user_id ``` ## Joining a search result {#search} [`fts()`](/sql/functions#fts) is a table function, so its result is a relation and joins like any other. This example assumes `my_docs` has `text` and `category_id` columns, a full-text index on `text`, and a `categories` table with `id` and `name` columns: ```sql SELECT c.name, count(*) AS hits FROM fts('my_docs', '{"match":{"column":"text","terms":"puppy"}}') AS m JOIN categories AS c ON c.id = m.category_id GROUP BY c.name ORDER BY hits DESC ``` That composition is the reason full-text search is a relation rather than a predicate: a `WHERE` clause has nowhere to put the relevance score. ## Performance {#performance} A [scalar index](/indexing/scalar-index) can accelerate supported filters on the input tables. An index on a join key does not by itself guarantee an index lookup join. Use SQL [`EXPLAIN`](/sql/dql/explain) to inspect the scans, filters, and join chosen for the complete statement. Avoid selecting blob or vector columns you do not need on either side of a join. The join multiplies rows before projection is applied in some plans, so a wide column can be materialized many more times than the result has rows. ## Late materialization for wide columns {#late-materialization} The planner can avoid the multiplication above: when a join narrows down to a key column (such as `_rowid` or another narrow, selective column) before a wide one is needed, it can defer reading a BlobV2 or vector column until after the join has already settled which rows survive, instead of reading it for every candidate row first. In a plan where this applies, the wide column is read once per surviving row, where the worker runs, and never pulled into the query node to be joined directly. ```sql SELECT d.title, b.caption FROM documents AS d JOIN (SELECT _rowid, caption FROM captions WHERE source = 'model-v2') AS b ON b._rowid = d._rowid ``` This is query planning, not a syntax you opt into: a join written this way is a **candidate** for the planner to defer the wide read, not a guarantee. Confirm it for a specific query with [`EXPLAIN`](/sql/dql/explain) -- `RemoteTake` in the plan means the deferral happened; its absence means the wide column was read eagerly, even if the SQL above matches exactly. Neither a passing query nor an `EXPLAIN` that omits `RemoteTake` tells you how much I/O a plan did or did not save -- read the plan's own row and byte counts for that.