diff --git a/docs/images/inner_join.svg b/docs/images/inner_join.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ae7d82c70accf88f620753d9a0a99aea774850ae
Binary files /dev/null and b/docs/images/inner_join.svg differ
diff --git a/docs/images/left_join.svg b/docs/images/left_join.svg
new file mode 100644
index 0000000000000000000000000000000000000000..70bfd86ea0009463f718d47ba2a329a738c9933a
Binary files /dev/null and b/docs/images/left_join.svg differ
diff --git a/docs/images/multiple_joins.svg b/docs/images/multiple_joins.svg
new file mode 100644
index 0000000000000000000000000000000000000000..7ab69ff82dc50e461cf14e002496b18238785985
Binary files /dev/null and b/docs/images/multiple_joins.svg differ
diff --git a/docs/reference/sql/join.md b/docs/reference/sql/join.md
new file mode 100644
index 0000000000000000000000000000000000000000..d8c4d85af33fcec5db54020bccab7df300b992e7
--- /dev/null
+++ b/docs/reference/sql/join.md
@@ -0,0 +1,365 @@
+# Использование JOIN {: #join }
+
+JOIN представляет собой параметр, используемый при SELECT-запросах с
+целью получения данных из двух или более таблиц. Данный параметр
+позволяет соединять колонки таблиц по заданному условию (оператор `ON`)
+и тем самым создавать новую результирующую таблицу из указанных столбцов
+изначальных таблиц. Соединение таблиц производится с использованием
+перекрестного (декартова) произведения их кортежей (строк).
+
+См. также:
+
+- [SELECT](select.md)
+
+## Расположение таблиц {: #join_tables }
+
+Запрос с соединением всегда подразумевает наличие _внешней_ таблицы, а
+также одной или нескольких _внутренних_ таблиц. Внешняя таблица
+находится слева от параметра `JOIN`, а внутренние — справа.
+
+Соединение таблиц всегда производится последовательно, в порядке,
+указанном в запросе:
+
+```sql
+"table1" JOIN "table2" JOIN "table3" -> ("table1" JOIN "table2") JOIN "table3"
+```
+
+## Типы соединения {: #join_types }
+
+Picodata поддерживает два типа соединения: `INNER JOIN` и `LEFT JOIN`.
+
+### INNER JOIN {: #inner_join }
+
+`INNER JOIN` — внутреннее соединение. Используется по умолчанию в тех
+случаях, когда в запросе указан параметр `JOIN` без уточнения.
+
+<div align="center">
+![INNER JOIN](../../images/inner_join.svg)
+</div>
+
+Данный тип означает, что к колонкам каждого кортежа из внутренней
+(правой) части запроса присоединяются только колонки тех кортежей
+внешней (левой) части, которые удовлетворяют условию соединения `ON`.
+Если во внешней части не нашлось подходящего кортежа, то внутренний
+кортеж не попадает в результат.
+
+### LEFT JOIN {: #left_join }
+
+`LEFT JOIN ` / `LEFT OUTER JOIN `— внешнее левое соединение.
+
+<div align="center">
+![LEFT JOIN](../../images/left_join.svg)
+</div>
+
+Данный тип означает, что к колонкам каждого кортежа из внешней (левой)
+части запроса присоединяются только колонки тех кортежей внутренней
+(правой) части, которые удовлетворяют условию соединения `ON`. Если во
+внутренней части не нашлось подходящего кортежа, то вместо значений
+его колонок будет подставлен `NULL`.
+
+## Условия соединения {: #join_condition }
+
+Условие соединения позволяет сопоставить строки разных таблиц и является
+обязательным для запросов со любым типом JOIN. Условие следует после
+ключевого слова `ON` и, в большинстве случаев, соответствует одному из
+следующих типов:
+
+- равенство колонок (`characters.id = assets.id`)
+- математическое выражение (`characters.id > 2`)
+- литерал (`TRUE` / `FALSE`)
+
+Любое соединение с JOIN является декартовым произведением кортежей из
+внешней и внутренней таблицы с фильтрацией по условию соединения.
+Поэтому результирующая таблица может быть как максимально возможного
+размера (например, при условии `ON TRUE` будут взаимно перемножены _все_
+кортежи), так и некоторого меньшего размера, в зависимости от заданного
+условия.
+
+См. также:
+
+- [Выражение (expression)](select.md#expression)
+
+## Примеры запросов {: #join_examples }
+
+Разница между левым и внутренним соединением проявляется в случаях,
+когда для части кортежей внешней таблицы отсутствуют подходящие под
+условие соединения кортежи из внутренней таблицы. При внутреннем
+соединении они будут отфильтрованы, при левом внешнем — оставлены, но на
+месте отсутствующих значений будет `nil`.
+
+Покажем это на примере соединения по равенству колонок для таблиц
+`characters` и `assets`:
+
+<details><summary>Содержимое таблиц</summary><p>
+
+```sql
+picodata> select * from "characters"
++----+-------------------+------+
+| id | name              | year |
++===============================+
+| 1  | "Woody"           | 1995 |
+|----+-------------------+------|
+| 2  | "Buzz Lightyear"  | 1995 |
+|----+-------------------+------|
+| 3  | "Bo Peep"         | 1995 |
+|----+-------------------+------|
+| 4  | "Mr. Potato Head" | 1995 |
+|----+-------------------+------|
+| 5  | "Woody"           | 1995 |
+|----+-------------------+------|
+| 10 | "Duke Caboom"     | 2019 |
++----+-------------------+------+
+(6 rows)
+picodata> select * from "assets"
++----+------------------+-------+
+| id | name             | stock |
++===============================+
+| 1  | "Woody"          | 2561  |
+|----+------------------+-------|
+| 2  | "Buzz Lightyear" | 4781  |
++----+------------------+-------+
+(2 rows)
+```
+</p></details>
+
+Пример левого соединения:
+
+```sql
+SELECT "characters"."name", "characters"."year", "assets"."stock"
+FROM "characters"
+LEFT JOIN "assets"
+ON "characters"."id" = "assets"."id"
+```
+
+Результат:
+
+```shell
++-------------------+-----------------+--------------+
+| characters.name   | characters.year | assets.stock |
++====================================================+
+| "Woody"           | 1995            | 2561         |
+|-------------------+-----------------+--------------|
+| "Buzz Lightyear"  | 1995            | 4781         |
+|-------------------+-----------------+--------------|
+| "Bo Peep"         | 1995            | nil          |
+|-------------------+-----------------+--------------|
+| "Mr. Potato Head" | 1995            | nil          |
+|-------------------+-----------------+--------------|
+| "Woody"           | 1995            | nil          |
+|-------------------+-----------------+--------------|
+| "Duke Caboom"     | 2019            | nil          |
++-------------------+-----------------+--------------+
+(6 rows)
+```
+
+Пример внутреннего соединения:
+
+```sql
+SELECT "characters"."name", "characters"."year", "assets"."stock"
+FROM "characters"
+INNER JOIN "assets"
+ON "characters"."id" = "assets"."id"
+```
+
+Результат:
+
+```shell
++------------------+-----------------+--------------+
+| characters.name  | characters.year | assets.stock |
++===================================================+
+| "Woody"          | 1995            | 2561         |
+|------------------+-----------------+--------------|
+| "Buzz Lightyear" | 1995            | 4781         |
++------------------+-----------------+--------------+
+(2 rows)
+```
+
+## Множественные соединения {: #multiple_joins }
+
+Соединять можно не только две, но и большее число таблиц. В запросе с
+несколькими соединениями могут быть использованы разные комбинации
+левого и внутреннего соединения.
+
+Для примера с двумя соединениями задействуем третью тестовую таблицу.
+
+<details><summary>Содержимое таблицы</summary><p>
+
+```sql
+picodata> select * from "cast"
++------------------+---------------+-------------+
+| character        | actor         | film        |
++================================================+
+| "Bo Peep"        | "Annie Potts" | "Toy Story" |
+|------------------+---------------+-------------|
+| "Buzz Lightyear" | "Tim Allen"   | "Toy Story" |
+|------------------+---------------+-------------|
+| "Woody"          | "Tom Hanks"   | "Toy Story" |
++------------------+---------------+-------------+
+(3 rows)
+```
+</details>
+
+Сделаем соединение трех таблиц с тем, чтобы узнать актеров всех
+персонажей из `characters` независимо от того, есть ли для
+соответствующих игрушек данные об остатках на складе.
+
+<p align="center">
+![MULTIPLE JOINS](../../images/multiple_joins.svg)
+</p>
+
+Запрос:
+
+```sql
+SELECT "characters"."name", "assets"."stock", "cast"."actor"
+FROM "characters"
+LEFT JOIN "assets"
+ON "characters"."id" = "assets"."id"
+JOIN "cast"
+ON "characters"."name" = "cast"."character"
+```
+
+Результат:
+
+```shell
++------------------+--------------+---------------+
+| characters.name  | assets.stock | cast.actor    |
++=================================================+
+| "Woody"          | 2561         | "Tom Hanks"   |
+|------------------+--------------+---------------|
+| "Buzz Lightyear" | 4781         | "Tim Allen"   |
+|------------------+--------------+---------------|
+| "Bo Peep"        | nil          | "Annie Potts" |
+|------------------+--------------+---------------|
+| "Woody"          | nil          | "Tom Hanks"   |
++------------------+--------------+---------------+
+(4 rows)
+```
+
+## Перемещение данных {: #join_motions }
+
+При выполнении распределенного запроса, соединение таблиц может
+сопровождаться полным или частичным перемещением данных, либо не
+требовать перемещения данных совсем. Критерием здесь выступает условие
+соединения (`ON`).
+
+При необходимости перемещения данных, в план выполнения запроса перед
+сканированием внутренней таблицы добавляется motion-узел, который
+обеспечивает перекачку недостающих данных на бакеты, содержащие данные
+внешней таблицы.
+
+См. также:
+
+- [EXPLAIN и варианты перемещения данных](explain.md#data_motion_types)
+
+### Отсутствие перемещения {: #no_motion }
+
+Перемещение данных не происходит, если в условии соединения использовано
+равенство колонок, которые входят в ключи распределения соответствующих
+таблиц.
+
+К примеру, в условии соединения указано `ON "characters"."id" =
+"assets"."id"`, и таблицы "characters" и "assets" обе распределены по
+своим колонкам "id":
+
+```sql
+picodata> EXPLAIN SELECT "characters"."name", "characters"."year", "assets"."stock"
+FROM "characters"
+LEFT JOIN "assets"
+ON "characters"."id" = "assets"."id"
+projection ("characters"."name"::string -> "name", "characters"."year"::integer -> "year", "assets"."stock"::integer -> "stock")
+    left join on ROW("characters"."id"::integer) = ROW("assets"."id"::integer)
+        scan "characters"
+            projection ("characters"."id"::integer -> "id", "characters"."name"::string -> "name", "characters"."year"::integer -> "year")
+                scan "characters"
+        scan "assets"
+            projection ("assets"."id"::integer -> "id", "assets"."name"::string -> "name", "assets"."stock"::integer -> "stock")
+                scan "assets"
+```
+
+### Частичное перемещение {: #segment_motion }
+
+Частичное перемещение означает, что недостающая часть внутренней таблицы
+должна быть скопирована на узлы, содержащие данные внешней таблицы.
+
+К примеру, в условии соединения указано `ON "characters"."id" =
+"assets"."id"`, но таблица "characters" распределена по колонке "id", а
+"assets" — по какой-то другой колонке:
+
+```sql
+picodata> EXPLAIN SELECT "characters"."name", "characters"."year", "assets"."stock"
+FROM "characters"
+LEFT JOIN "assets"
+ON "characters"."id" = "assets"."id"
+projection ("characters"."name"::string -> "name", "characters"."year"::integer -> "year", "assets2"."stock"::integer -> "stock")
+    left join on ROW("characters"."id"::integer) = ROW("assets"."id"::integer)
+        scan "characters"
+            projection ("characters"."id"::integer -> "id", "characters"."name"::string -> "name", "characters"."year"::integer -> "year")
+                scan "characters"
+        motion [policy: segment([ref("id")])]
+            scan "assets"
+                projection ("assets"."id"::integer -> "id", "assets"."name"::string -> "name", "assets"."stock"::integer -> "stock")
+                    scan "assets"
+```
+
+### Полное перемещение {: #full_motion }
+
+Полное перемещение означает, что вся внутренняя таблица
+должна быть скопирована на узлы, содержащие данные внешней таблицы.
+
+Такая ситуация возникает, если в условии соединения указана колонка
+внешней таблицы, не входящая в ее ключ распределения.
+
+К примеру, в условии соединения указано `ON "characters"."id" =
+"assets"."id"`, но таблица "characters" распределена по какой-то другой
+колонке, в то время как "assets" распределена по "id":
+
+```sql
+picodata> EXPLAIN SELECT "characters"."name", "characters"."year", "assets"."stock"
+FROM "characters"
+LEFT JOIN "assets"
+ON "characters"."id" = "assets"."id"
+projection ("characters"."name"::string -> "name", "characters"."year"::integer -> "year", "assets"."stock"::integer -> "stock")
+    left join on ROW("characters"."id"::integer) = ROW("assets"."id"::integer)
+        scan "characters"
+            projection ("characters"."id"::integer -> "id", "characters"."name"::string -> "name", "characters"."year"::integer -> "year")
+                scan "characters"
+        motion [policy: full]
+            scan "assets"
+                projection ("assets"."id"::integer -> "id", "assets"."name"::string -> "name", "assets"."stock"::integer -> "stock")
+                    scan "assets"
+```
+
+Также, при использовании математических выражений или литералов, перемещение всегда будет полным:
+
+```sql
+picodata> EXPLAIN SELECT "characters"."name", "characters"."year", "assets"."stock"
+FROM "characters"
+LEFT JOIN "assets"
+ON "characters"."id" = 1
+projection ("characters"."name"::string -> "name", "characters"."year"::integer -> "year", "assets"."stock"::integer -> "stock")
+    left join on ROW("characters"."id"::integer) = ROW(1::unsigned)
+        scan "characters"
+            projection ("characters"."id"::integer -> "id", "characters"."name"::string -> "name", "characters"."year"::integer -> "year")
+                scan "characters"
+        motion [policy: full]
+            scan "assets"
+                projection ("assets"."id"::integer -> "id", "assets"."name"::string -> "name", "assets"."stock"::integer -> "stock")
+                    scan "assets"
+```
+
+```sql
+picodata> EXPLAIN SELECT "characters"."name", "characters"."year", "assets"."stock"
+FROM "characters"
+LEFT JOIN "assets"
+ON TRUE
+projection ("characters"."name"::string -> "name", "characters"."year"::integer -> "year", "assets"."stock"::integer -> "stock")
+    left join on true::boolean
+        scan "characters"
+            projection ("characters"."id"::integer -> "id", "characters"."name"::string -> "name", "characters"."year"::integer -> "year")
+                scan "characters"
+        motion [policy: full]
+            scan "assets"
+                projection ("assets"."id"::integer -> "id", "assets"."name"::string -> "name", "assets"."stock"::integer -> "stock")
+                    scan "assets"
+
+```
diff --git a/docs/reference/sql/select.md b/docs/reference/sql/select.md
index 8b32ac76a19ab9ce6b0560d9656fdeda6752e607..8fcad789f02acbbd5ca913295fa5bb6369c6acb0 100644
--- a/docs/reference/sql/select.md
+++ b/docs/reference/sql/select.md
@@ -74,7 +74,11 @@ NOTE: **Примечание** Кортежи в выводе идут в том
   несколько `EXCEPT DISTINCT` подряд. Чтобы обойти это ограничение,
   следует воспользоваться подзапросами.
 
-## Примеры {: #examples }
+См. также:
+
+- [Использование JOIN](join.md)
+
+## Примеры  {: #examples }
 
 Получение данных из таблицы с фильтрацией:
 
diff --git a/docs/sql_index.md b/docs/sql_index.md
index 40041bb24cf1c1efe59ea013d155db9c40150e09..1c0257a68983b49bbccdd2824d2a55fdbe0e54bd 100644
--- a/docs/sql_index.md
+++ b/docs/sql_index.md
@@ -32,6 +32,10 @@
 * [UPDATE](reference/sql/update.md)
 * [VALUES](reference/sql/values.md)
 
+## Синтаксис {: #syntax }
+
+* [Использование JOIN](reference/sql/join.md)
+
 ## Функции и операторы {: #functions }
 
 * [Агрегатные функции](reference/sql/aggregate.md)
diff --git a/mkdocs.yml b/mkdocs.yml
index c5da806a8fb902f4174b5158e130df5db1042d93..04e4a8da570948a401a7009322f91775f125a93a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -75,6 +75,8 @@ nav:
         - reference/sql/select.md
         - reference/sql/update.md
         - reference/sql/values.md
+      - Синтаксис:
+        - reference/sql/join.md
       - Функции и операторы:
         - reference/sql/aggregate.md
         - reference/sql/cast.md