JOIN
Joins combine the main table with others in a SELECT. Each join method takes the table name plus a closure that builds the ON conditions on a JoinClause accumulator — except cross_join, which by definition has no condition. Joins are SELECT-only: they are ignored for INSERT/UPDATE/DELETE. Join tables are escaped like every identifier, and if the builder has a .db() qualifier it prefixes the join tables as well.
Join kinds
| Method | Emits |
|---|---|
join(table, f) | INNER JOIN table ON … |
left_join(table, f) | LEFT JOIN table ON … |
right_join(table, f) | RIGHT JOIN table ON … |
full_outer_join(table, f) | FULL OUTER JOIN table ON … |
cross_join(table) | CROSS JOIN table (no ON) |
let (sql, binds) = QueryBuilder::<Postgres>::table("users")
.select(["users.id"])
.join("orders", |j| j.on("orders.user_id", "=", "users.id"))
.left_join("profiles", |j| j.on("profiles.user_id", "=", "users.id"))
.to_sql();
// SELECT "users"."id" FROM "users" INNER JOIN "orders" ON "orders"."user_id" = "users"."id" LEFT JOIN "profiles" ON "profiles"."user_id" = "users"."id"let (sql, _) = QueryBuilder::<Postgres>::table("a")
.select(["id"])
.right_join("b", |j| j.on("b.a_id", "=", "a.id"))
.full_outer_join("c", |j| j.on("c.a_id", "=", "a.id"))
.to_sql();
// SELECT "id" FROM "a" RIGHT JOIN "b" ON "b"."a_id" = "a"."id" FULL OUTER JOIN "c" ON "c"."a_id" = "a"."id"cross_join takes no closure — a cross join has no condition:
let (sql, binds) = QueryBuilder::<Postgres>::table("a")
.select(["id"])
.cross_join("b")
.to_sql();
// SELECT "id" FROM "a" CROSS JOIN "b"Dialect note — the builder renders all five kinds identically on every dialect; whether the server accepts them is up to the database. Notably MySQL has no
FULL OUTER JOIN, and SQLite only gainedRIGHT/FULL OUTER JOINin 3.39 — the builder does not guard against this, the database rejects the query at execution time.
Building ON conditions
The closure receives an empty JoinClause and chains conditions; multiple conditions are joined with AND. There are three condition kinds:
on — column to column
on(col, op, col2): both sides are identifiers, escaped at compile time. The operator is a &'static str, so it cannot come from runtime input:
let (sql, _) = QueryBuilder::<MySql>::table("users")
.select(["id"])
.join("orders", |j| j.on("orders.user_id", "=", "users.id"))
.to_sql();
// SELECT `id` FROM `users` INNER JOIN `orders` ON `orders`.`user_id` = `users`.`id`on_val — column to bound value
on_val(col, op, val): the column is escaped, the value becomes a placeholder + bind — the same discipline as WHERE. Join binds are emitted before WHERE binds, because the JOIN clause renders first:
let (sql, binds) = QueryBuilder::<Postgres>::table("users")
.select(["id"])
.join("orders", |j| {
j.on("orders.user_id", "=", "users.id")
.on_val("orders.status", "=", "paid")
})
.where_eq("users.active", true)
.to_sql();
// SELECT "id" FROM "users" INNER JOIN "orders" ON "orders"."user_id" = "users"."id" AND "orders"."status" = $1 WHERE "users"."active" = $2
// binds == [Value::Text("paid"), Value::Bool(true)]on_raw — verbatim fragment
on_raw(sql, binds) is the escape hatch for ON conditions the structured API cannot express:
⚠️ Positional placeholder contract
The fragment is emitted verbatim — it is NOT escaped and NOT renumbered;
bindsare appended to the running bind list in order. On Postgres you must hand-write$Nnumbers matching the actual bind position; on MySQL/SQLite use?. See the Security Model for the full escape-hatch inventory.
let (sql, binds) = QueryBuilder::<Postgres>::table("a")
.select(["id"])
.join("b", |j| {
j.on_raw(
r#""b"."a_id" = "a"."id" AND "b"."n" > $1"#,
vec![Value::I64(5)],
)
})
.to_sql();
// SELECT "id" FROM "a" INNER JOIN "b" ON "b"."a_id" = "a"."id" AND "b"."n" > $1Note that identifiers inside the raw fragment must be quoted by hand (the example writes "b"."a_id" itself).
.db() qualifies join tables too
A builder-level db("name") qualifier prefixes the main table and every join table — the multi-tenant assumption is that all tables live in the same tenant database:
let (sql, _) = QueryBuilder::<Postgres>::table("users")
.db("mydb")
.select(["users.id"])
.left_join("profiles", |j| j.on("users.id", "=", "profiles.uid"))
.to_sql();
// SELECT "users"."id" FROM "mydb"."users" LEFT JOIN "mydb"."profiles" ON "users"."id" = "profiles"."uid"Column references in select/on are not rewritten — qualify them with the table name as usual.
Related pages
- SELECT — choosing columns across joined tables
- WHERE — filtering after the join
- Multi-tenant with
.db()— one pool, many schemas - Security Model —
on_rawand the other escape hatches