How to JOIN a Second Table in CodeIgniter Query Builder
Can you add a second table to a SELECT query you've already started building in CodeIgniter? Yes. As long as the query hasn't been executed, you can call join() on the existing Query Builder, add columns with select(), and execute everything together using get().
The distinction is between an unfinished query and an executed query. CodeIgniter lets you extend the first, but you cannot retroactively add a SQL JOIN to results that have already been retrieved from MySQL.
Here's how to handle both cases, including the differences between CodeIgniter 3 and CodeIgniter 4.
How It Works: Adding a JOIN to an Existing SELECT
Imagine an application with two MySQL tables.
Table: orders
| id | customer_id | total_amount | status |
|---|---|---|---|
| 101 | 1 | 250.00 | paid |
| 102 | 2 | 180.00 | pending |
| 103 | 3 | 420.00 | paid |
Table: customers
| id | name |
|---|---|
| 1 | Ahmed |
| 2 | Sara |
| 3 | Ali |
Your application already selects paid orders, but now the dashboard also needs the customer's name.
The SQL you want is:
SELECT
o.id,
o.total_amount,
o.status,
c.name AS customer_name
FROM orders AS o
LEFT JOIN customers AS c
ON c.id = o.customer_id
WHERE o.status = 'paid';
Expected result:
| id | total_amount | status | customer_name |
|---|---|---|---|
| 101 | 250.00 | paid | Ahmed |
| 103 | 420.00 | paid | Ali |
The JOIN connects orders.customer_id with customers.id.
The important part is that the JOIN belongs to the SQL query itself, not to the PHP array returned afterward.
1. Reproduce the problem in CodeIgniter 3
You might already have:
$this->db->select('o.id, o.total_amount, o.status');
$this->db->from('orders AS o');
$this->db->where('o.status', 'paid');
At this point, no SQL query has run.
CodeIgniter is collecting the SELECT, FROM, and WHERE instructions.
You can still extend it.
2. Add the second table before calling get()
The fix is straightforward:
$this->db->join(
'customers AS c',
'c.id = o.customer_id',
'left'
);
$this->db->select('c.name AS customer_name');
$query = $this->db->get();
$rows = $query->result_array();
The existing SELECT and WHERE conditions remain part of the query.
Calling select() again adds another field rather than replacing the earlier fields.
Likewise, join() adds the JOIN clause to the query being assembled.
For clarity, here is the complete CodeIgniter 3 example as a controller.
Create application/controllers/Orders.php:
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Orders extends CI_Controller
{
public function index()
{
$this->load->database();
$this->db->select(
'o.id, o.total_amount, o.status'
);
$this->db->from('orders AS o');
$this->db->where('o.status', 'paid');
$this->db->join(
'customers AS c',
'c.id = o.customer_id',
'left'
);
$this->db->select(
'c.name AS customer_name'
);
$query = $this->db->get();
$rows = $query->result_array();
$this->output
->set_content_type('application/json')
->set_output(json_encode($rows));
}
}
Assuming those tables and columns exist and your database is configured, opening /orders returns JSON containing the matching orders and customer names.
This uses CodeIgniter 3's documented Query Builder methods.
Reference: CodeIgniter 3 Query Builder documentation.
3. Apply the same fix in CodeIgniter 4
CodeIgniter 4 separates the database connection from the Query Builder instance.
Instead of building through $this->db, you normally create a builder using $db->table().
The equivalent code is:
<?php
$db = \Config\Database::connect();
$builder = $db->table('orders AS o');
$builder->select(
'o.id, o.total_amount, o.status'
);
$builder->where('o.status', 'paid');
$builder->join(
'customers AS c',
'c.id = o.customer_id',
'left'
);
$builder->select('c.name AS customer_name');
$query = $builder->get();
$rows = $query->getResultArray();
Use this inside your existing CodeIgniter 4 controller or model, where the framework has already been initialized.
Notice the differences:
| Operation | CodeIgniter 3 | CodeIgniter 4 |
|---|---|---|
| Builder | $this->db | $db->table() |
| Add JOIN | join() | join() |
| Run SELECT | get() | get() |
| Array results | result_array() | getResultArray() |
| Preview SQL | get_compiled_select() | getCompiledSelect() |
The underlying SQL concept is identical.
Reference: CodeIgniter 4 Query Builder documentation.
Where You Will Meet This in Real Applications
Adding customer details to existing reports
Suppose your reporting function already selects orders by status and date.
Later, the business asks to display customer information alongside each order.
Rather than running a separate customer query for every order, add one JOIN before executing the existing builder.
This usually produces simpler application code and avoids the N+1 query pattern.
Adding optional information with LEFT JOIN
Not every order necessarily has a matching customer record.
A LEFT JOIN retains the order even when the customer cannot be matched.
$builder->join(
'customers AS c',
'c.id = o.customer_id',
'left'
);
The unmatched customer columns will contain NULL.
If you use an INNER JOIN instead:
$builder->join(
'customers AS c',
'c.id = o.customer_id',
'inner'
);
Only orders with matching customers will appear.
This distinction matters for incomplete imports, historical records, and optional relationships.
Adding multiple related tables
A report may also need payment information.
In CodeIgniter 4, you can add another JOIN:
$builder->join(
'payments AS p',
'p.order_id = o.id',
'left'
);
$builder->select(
'p.id AS payment_id, p.amount AS payment_amount'
);
This assumes the payments table contains those columns.
Be careful: if an order has several payments, that order may appear in multiple result rows.
That is normal SQL JOIN behavior, not a CodeIgniter bug.
If you need one row per order, consider aggregation, a suitable subquery, or a different reporting query.
Common Misunderstandings About CodeIgniter JOINs
"I already called get(). Can I add another JOIN?"
No, not to the result that was already executed.
This approach is incorrect:
$query = $this->db->get('orders');
$this->db->join(
'customers',
'customers.id = orders.customer_id'
);
The first statement already fetched orders.
The later join() builds part of another query; it cannot change the previous result.
Fix: Move the JOIN before get(), or rebuild and execute the required SELECT.
If the pre-existing query is a raw SQL string rather than an unfinished builder, rebuild it through Query Builder or use a correctly constructed SQL query. You cannot call join() directly on a SQL string.
"Why does LEFT JOIN behave like INNER JOIN?"
Consider:
$builder->join(
'customers AS c',
'c.id = o.customer_id',
'left'
);
$builder->where('c.name', 'Ahmed');
The WHERE condition excludes rows without a matching customer because their c.name is NULL.
Consequently, unmatched orders disappear.
If you genuinely need to retain unmatched rows while filtering matching customers, reconsider whether the condition belongs in the JOIN's ON clause instead.
The correct approach depends on your desired results.
"Why am I getting ambiguous column errors?"
Both tables may contain a column named id.
This is ambiguous:
$builder->select('id, name');
Prefer qualified columns:
$builder->select('o.id, c.name AS customer_name');
Use table aliases consistently in SELECT, JOIN, WHERE, and ORDER BY clauses.
Also avoid SELECT * when joining tables with duplicate column names. Duplicate keys can overwrite each other when results are converted into associative arrays.
"Why does my second query behave differently?"
CodeIgniter's builder maintains state until it is reset or executed.
In CodeIgniter 4, use a new $db->table() builder for a separate query, or explicitly reset an existing builder when appropriate.
In CodeIgniter 3, ordinary execution through get() resets the query-building state.
Do not assume that a previously executed query still contains its original SELECT and JOIN clauses.
"Is Query Builder automatically safe from SQL injection?"
Query Builder provides value escaping and identifier protection for supported operations, but it does not make arbitrary SQL fragments safe.
Never concatenate untrusted request parameters into a JOIN condition.
Keep table names and relationship expressions under application control, and pass user-supplied values through supported parameterized or value-binding methods.
Debug the Generated SQL Before Running It
When a JOIN produces unexpected results, I prefer inspecting the generated SQL before modifying more PHP code.
In CodeIgniter 3:
$sql = $this->db->get_compiled_select('', FALSE);
echo $sql;
The FALSE argument prevents the builder from being reset.
That allows you to inspect the query and then execute it:
$query = $this->db->get();
In CodeIgniter 4:
$sql = $builder->getCompiledSelect(false);
echo $sql;
Then:
$rows = $builder->get()->getResultArray();
Use this for local debugging rather than displaying SQL publicly in production.
Check that the generated SQL includes the intended:
- SELECT columns.
- Primary table and aliases.
- JOIN type.
- ON relationship.
- WHERE conditions.
If the SQL is correct but the output is wrong, investigate the underlying data and relationship cardinality.
When Adding a JOIN Matters—and When It Doesn't
A JOIN is useful when related data must be retrieved together, especially for dashboards, search results, and reports.
It is not automatically the best choice for every relationship.
For large datasets, inspect indexes and query execution plans. An index on a frequently joined foreign-key column, such as orders.customer_id, may help the database find matching records efficiently.
For one-to-many relationships, joining all records can substantially increase the number of returned rows. Sometimes a grouped query or separate targeted retrieval is more appropriate.
For an already executed query, a JOIN requires another database operation. PHP cannot retroactively modify the SQL that MySQL has completed.
The practical rule is simple: finish constructing the SELECT, add the JOIN and required columns, then execute the query once.
That pattern works across both CodeIgniter 3 and CodeIgniter 4, even though their builder initialization and result-handling methods differ.
If your query still returns incorrect results, inspect the generated SQL first. Most problems come from mismatched relationship columns, duplicate column names, incorrect JOIN types, or filtering conditions—not from the join() method itself.


