Paginating Database Results in CodeIgniter 4 Without the Usual Headaches
CodeIgniter 4's paginate() and Pager service give you correct pagination in a few lines — if you know about the COUNT query cost, named groups, and dropped filter parameters.
14 Dec 2025, 13:45 UTC

You have a product catalog, a blog archive, or an admin list, and dumping all 4,000 rows onto one page is no longer acceptable. The good news: CodeIgniter 4 ships a Pagination library that handles the COUNT query, the LIMIT/OFFSET slice, and the page links for you. The less good news: there are a few defaults — the ?page= parameter, the per-request COUNT query, and dropped filter parameters — that will bite you if you don't know about them upfront.
This post walks through the standard pattern, a worked example, and the trade-offs worth knowing before you ship it. Everything here assumes CodeIgniter 4; CodeIgniter 3 uses different class names and configuration, so check your installed version's user guide before copying anything.
The core pattern: paginate() plus $pager->links()
In CodeIgniter 4, pagination is split between two pieces. The Model (or Query Builder) figures out which rows belong on the current page, and the Pager service renders the navigation links. A minimal controller looks like this:
public function index()
{
$productModel = new \App\Models\ProductModel();
$data = [
'products' => $productModel
->where('active', 1)
->paginate(12),
'pager' => $productModel->pager,
];
return view('catalog/index', $data);
}Two things happen inside paginate(12): a COUNT(*) query to determine the total number of matching rows, and a SELECT ... LIMIT 12 OFFSET n query for the current slice. The current page number comes from the page query string parameter by default — so page three is /catalog?page=3, not a URI segment. That keeps your routes simple and avoids fighting with the router.
In the view, rendering the links is one line:
<?= $pager->links() ?>That emits a full page-navigation list using the default template. CodeIgniter ships templates styled for common CSS frameworks in app/Views/Pagers, and you can register your own template in app/Config/Pager.php if your markup needs differ.
Two lists on one page: named groups
A dashboard with two independently paginated tables is a common requirement, and it's where naive implementations fall apart — clicking "next" on one list resets the other. The Pager service solves this with named groups:
$data = [
'orders' => $orderModel->paginate(10, 'orders'),
'tickets' => $ticketModel->paginate(10, 'tickets'),
'ordersPager' => $orderModel->pager,
'ticketsPager' => $ticketModel->pager,
];Then in the view:
<?= $ordersPager->links('orders') ?>
<?= $ticketsPager->links('tickets') ?>Each group tracks its own page parameter, so the two lists paginate independently. This is one of those features that's easy to miss in the docs and painful to hand-roll.
The trade-offs nobody mentions first
Offset pagination has real costs, and you should know where the floor is:
- Every request runs a COUNT query. On a table with millions of rows and a
WHEREclause, that count can dominate response time. If profiling shows it hurting, cache the count or drop the "total pages" display entirely. - Deep pages get slower linearly. Page 500 with 20 rows per page means the database scans and discards roughly 10,000 rows before returning yours. If users legitimately browse deep, consider cursor/keyset pagination (
WHERE id > :last_seen ORDER BY id LIMIT 20) instead — though you'll build the links yourself, since the Pager service is offset-based. - Filters can silently vanish between pages. If your list is filtered by search or sort parameters, the generated links may not carry them forward depending on your configuration. Verify by applying a filter, clicking to page two, and checking the URL still contains your filter parameters. If they're gone, ensure the pager is configured to include the existing query string.
Verifying it actually works
Don't trust it until you've watched the queries. Enable the debug toolbar (it ships with CodeIgniter 4's development environment) and confirm that loading a page executes exactly one COUNT and one LIMIT/OFFSET select — not one query per row, and not a full-table fetch sliced in PHP. Then seed ~100 rows, set paginate(10), and click through the rendered links to confirm each page shows a distinct slice and the final page shows the remainder.
If you're building an API or an HTMX endpoint where the client controls paging explicitly, paginate() also accepts a page number override as a later argument — check your version's user guide for the exact signature, since parameter order has changed across releases.
Closing
For typical admin panels, catalogs, and archives, $model->paginate() plus $pager->links() gets you correct, framework-supported pagination in about five lines. Reach for named groups the moment a second list appears, and keep an eye on the COUNT query and deep-offset cost as your tables grow. Start with the built-in tool; switch to cursor pagination only when profiling says you must.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.