{"id":794,"date":"2026-10-21T13:03:20","date_gmt":"2026-10-21T13:03:20","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/laravel-db-olap-planetscale-100000-row-limit-when-i-still-chunk\/"},"modified":"2026-10-21T13:03:20","modified_gmt":"2026-10-21T13:03:20","slug":"laravel-db-olap-planetscale-100000-row-limit-when-i-still-chunk","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/laravel-db-olap-planetscale-100000-row-limit-when-i-still-chunk\/","title":{"rendered":"Laravel DB::olap() on PlanetScale: When I&#8217;d Still Chunk"},"content":{"rendered":"<p>I read Paul Redmond&rsquo;s write-up of the new <code>DB::olap()<\/code> method on <a href=\"https:\/\/laravel-news.com\/laravel-planetscale-olap\" rel=\"nofollow noopener\" target=\"_blank\">Laravel News<\/a> this morning, and my first reaction was a mix of &ldquo;nice, small, useful&rdquo; and &ldquo;I want to be suspicious of this before I trust it&rdquo;. I haven&rsquo;t run it against a PlanetScale branch yet. So everything below is a read of the article, the pull request and PlanetScale&rsquo;s docs, plus some opinions about where I&rsquo;d get burned. Not a benchmark.<\/p>\n<p>Short version for the impatient: <code>DB::olap()<\/code> is a tidy way to lift PlanetScale&rsquo;s per-query row cap for one callback. It is good for occasional exports and backfills. It is a bad default for anything a user is waiting on, and it has one trap that code review will miss.<\/p>\n<h2 id=\"what-the-limit-actually-is\">What the limit actually is<\/h2>\n<p>PlanetScale runs MySQL on Vitess, and by default every query runs in an OLTP workload with guard rails. According to the <a href=\"https:\/\/planetscale.com\/docs\/vitess\/troubleshooting\/planetscale-system-limits\" rel=\"nofollow noopener\" target=\"_blank\">PlanetScale system limits page<\/a>, a query there can return, update or delete at most 100,000 rows, and a result set tops out at 64 MiB. Those numbers exist so one lazy report can&rsquo;t starve everyone else on the cluster.<\/p>\n<p>The part that bites Laravel developers is not the limit itself. It&rsquo;s that nothing on your laptop enforces it. A local MySQL container happily returns two million rows, your export job passes its test with a seeded table of fifty rows, and the first time it meets a real customer with three years of orders it falls over in production. The code is identical. The database is not.<\/p>\n<p>That&rsquo;s an old category of bug, and I&rsquo;ve never found a cure other than running against something shaped like production. I wrote about a different flavour of the same problem, <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-postgres-unindexed-foreign-keys-vacuum-lint-in-ci\" rel=\"noopener\">indexes your tests never notice are missing<\/a>, and the lesson there applies here: a green test suite on a small database proves very little about a big one.<\/p>\n<p>Vitess has a second workload, OLAP, that drops those limits. Until now, using it from Laravel meant reaching for a raw statement.<\/p>\n<h2 id=\"the-old-way-and-the-new-way\">The old way and the new way<\/h2>\n<p>Before Laravel 13.36, running one query in OLAP mode looked like this:<\/p>\n<pre><code class=\"language-php\">try {\n    DB::statement(&quot;set workload = 'olap'&quot;);\n\n    $rows = Order::query()\n        -&gt;where('created_at', '&gt;=', now()-&gt;subYears(3))\n        -&gt;get();\n} finally {\n    DB::statement(&quot;set workload = 'oltp'&quot;);\n}\n<\/code><\/pre>\n<p>Two things are wrong with that, and the article spells both out. The raw statement runs on the write connection, so if you have read replicas the <code>select<\/code> goes to a read connection that never left OLTP mode. You set the flag and the query ignored it. And because the workload is a session setting, a missing <code>finally<\/code> leaves the connection in OLAP for whatever runs next, which in a queue worker or an Octane process is somebody else&rsquo;s job.<\/p>\n<p>The new method, added in <a href=\"https:\/\/github.com\/laravel\/framework\/pull\/61932\" rel=\"nofollow noopener\" target=\"_blank\">this framework pull request<\/a>, wraps all of that:<\/p>\n<pre><code class=\"language-php\">$revenue = DB::olap(fn () =&gt; Order::query()\n    -&gt;selectRaw(&quot;customer_id, date_format(created_at, '%Y-%m') as month, sum(total) as revenue&quot;)\n    -&gt;where('created_at', '&gt;=', now()-&gt;subYears(3))\n    -&gt;groupBy('customer_id', 'month')\n    -&gt;get()\n);\n<\/code><\/pre>\n<p>It sets the workload on both the write and the read connection, runs your callback, and flips back to OLTP in a <code>finally<\/code> block. Whatever the callback returns, you get back. If the database isn&rsquo;t Vitess, it just calls the callback and does nothing, which means the same code runs on local MySQL and in CI without a branch.<\/p>\n<p>I like that last decision a lot. A helper that only works against one vendor would force an <code>if<\/code> at every call site, and people would forget it. A no-op elsewhere is the right default.<\/p>\n<p>One catch: the method lives on the MySQL connection class. If your default connection is PostgreSQL or SQLite, <code>DB::olap()<\/code> throws a <code>BadMethodCallException<\/code>. That matters if you run SQLite in tests and MySQL in production. You&rsquo;d get a failure in the one place you didn&rsquo;t want one.<\/p>\n<h2 id=\"the-trap-lazy-results-leave-the-callback\">The trap: lazy results leave the callback<\/h2>\n<p>This is the part I&rsquo;d flag in every pull request that uses the method.<\/p>\n<p>The workload flips back the moment the callback returns. So this looks right and isn&rsquo;t:<\/p>\n<pre><code class=\"language-php\">$orders = DB::olap(fn () =&gt; Order::query()-&gt;cursor());\n\nforeach ($orders as $order) {\n    \/\/ the query actually runs here, back in OLTP mode\n}\n<\/code><\/pre>\n<p><code>cursor()<\/code>, <code>lazy()<\/code> and <code>lazyById()<\/code> return a <code>LazyCollection<\/code>, and a lazy collection doesn&rsquo;t touch the database until something iterates it. The arrow function returns the collection, <code>olap()<\/code> resets the workload, and then your <code>foreach<\/code> triggers the real query under the old limits. The code reads as correct. It fails, or worse, silently runs under different rules than you think.<\/p>\n<p>The fix is boring: iterate inside the callback, or call <code>get()<\/code> there. The article&rsquo;s queued export does the first. But &ldquo;boring fix&rdquo; and &ldquo;easy to notice in review&rdquo; are different things. An arrow function returning a query builder chain looks identical whether the last call is <code>get()<\/code> or <code>cursor()<\/code>.<\/p>\n<p>There&rsquo;s a second, quieter limit. OLAP removes PlanetScale&rsquo;s 64 MiB result cap, but it doesn&rsquo;t remove PHP&rsquo;s <code>memory_limit<\/code>. A <code>get()<\/code> over three years of orders moves the ceiling from the database to your worker&rsquo;s memory. I&rsquo;d pair <code>olap()<\/code> with <code>lazyById()<\/code> and a loop that writes rows out as it goes, and I&rsquo;d treat a bare <code>get()<\/code> over a big table as a smell even when the database allows it.<\/p>\n<h2 id=\"where-i-would-still-chunk\">Where I would still chunk<\/h2>\n<p>PlanetScale&rsquo;s own docs recommend against OLAP for regular application queries. The article summarises that guidance: the OLTP limits stop long-running queries from using up the database&rsquo;s resources, and large reads are better paginated or copied to a separate analytics database. It also says OLAP queries can fail if the connection to PlanetScale is interrupted.<\/p>\n<p>That last point changes how I&rsquo;d write an export. Suppose a queued job streams a million rows inside one <code>olap()<\/code> call and the connection drops at row 900,000. The job retries, and it starts over from row one. For a nightly report nobody is waiting on, that may be fine. For anything with a deadline, I&rsquo;d rather have a job that remembers where it stopped:<\/p>\n<pre><code class=\"language-php\">public function handle(): void\n{\n    $lastId = Cache::get(&quot;export:{$this-&gt;path}:last_id&quot;, 0);\n\n    Order::query()\n        -&gt;where('id', '&gt;', $lastId)\n        -&gt;chunkById(5000, function ($orders) {\n            $this-&gt;append($orders);\n            Cache::put(&quot;export:{$this-&gt;path}:last_id&quot;, $orders-&gt;last()-&gt;id, now()-&gt;addDay());\n        });\n}\n<\/code><\/pre>\n<p>That runs entirely in OLTP mode, each chunk is a small query well under every limit, and a retry picks up near where it died. It&rsquo;s more code than <code>DB::olap()<\/code>. It&rsquo;s also the version I&rsquo;d bet on at 2am.<\/p>\n<p>My rough rule: reach for <code>olap()<\/code> when the job is one-off or rare and a restart is cheap. A backfill, an admin report, a data fix. Reach for chunking when the work is recurring, user-facing, or expensive to repeat. And for reporting that keeps growing, stop reading the production database at all and copy the data somewhere built for it, which is exactly what PlanetScale suggests.<\/p>\n<p>The article frames <code>olap()<\/code> around reads, and I&rsquo;d keep it there. I haven&rsquo;t seen anything that tells me the OLAP workload is a good idea for the update and delete side of that 100,000-row limit, so for bulk writes I&rsquo;d still chunk and not assume.<\/p>\n<h2 id=\"a-guard-id-put-around-it\">A guard I&rsquo;d put around it<\/h2>\n<p>Because the method is so easy to call, I&rsquo;d wrap it once and make the dangerous uses harder. Something like:<\/p>\n<pre><code class=\"language-php\">final class HeavyRead\n{\n    public static function run(Closure $callback): mixed\n    {\n        if (! app()-&gt;runningInConsole()) {\n            throw new LogicException('Heavy reads belong in queued jobs or commands.');\n        }\n\n        $start = microtime(true);\n\n        try {\n            return DB::olap($callback);\n        } finally {\n            Log::info('heavy read finished', [\n                'seconds' =&gt; round(microtime(true) - $start, 1),\n            ]);\n        }\n    }\n}\n<\/code><\/pre>\n<p>It refuses to run inside a web request, so nobody wires a &ldquo;download all orders&rdquo; button straight to an unlimited query. It logs a duration, so you notice when a one-off report quietly becomes a nightly habit. And with the one call site in one file, reviewing for the lazy-collection mistake is a single grep.<\/p>\n<p>Two smaller details from the article are worth knowing. The same pull request adds <code>isVitess()<\/code> to the MySQL connection, which returns true when the server&rsquo;s version string mentions Vitess or PlanetScale. Like <code>isMaria()<\/code>, it connects to the database to read that version, so the first call costs a round trip. And if your read connection hasn&rsquo;t connected yet when you call <code>olap()<\/code>, the method connects it first. Neither is a problem. Both are the kind of thing I&rsquo;d want to know before I saw an unexpected connection in a profiler.<\/p>\n<h2 id=\"what-i-would-do-this-week\">What I would do this week<\/h2>\n<p>Search your codebase for exports, reports and backfills that call <code>get()<\/code> or <code>all()<\/code> on a table that has grown. Those are your candidates, and they&rsquo;re also the jobs most likely to have never been run against production-sized data.<\/p>\n<p>Then pick one and test it against a PlanetScale development branch with a realistic row count, not a seeded fifty. If it fails on the limit, you have two honest options: wrap it in <code>DB::olap()<\/code> with iteration inside the callback, or rewrite it with <code>chunkById()<\/code> so a retry resumes. Decide per job using the restart-cost rule above.<\/p>\n<p>Last, grep for <code>set workload<\/code> in your repository. Any raw statement you find is a replica-routing bug or a leaked session waiting to happen, and swapping it for <code>DB::olap()<\/code> is a five-minute change.<\/p>\n<p>If you&rsquo;re running Laravel on PlanetScale and want a second pair of eyes on an export that keeps timing out, that&rsquo;s the kind of backend work I do. You can see more of it on my <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">portfolio<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Laravel 13.36 adds DB::olap() to lift PlanetScale&#8217;s 100,000-row query limit. Here is the lazy cursor trap, when I&#8217;d still chunk instead, and a guard to wrap it in.<\/p>\n","protected":false},"author":2,"featured_media":793,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Laravel 13.36 adds DB::olap() to lift PlanetScale's 100,000-row query limit. Here is the lazy cursor trap, when I'd still chunk instead, and a guard to wrap it in.","rank_math_focus_keyword":"planetscale","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[869],"tags":[56,748,53,870,871],"class_list":["post-794","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-php-laravel","tag-laravel","tag-mysql","tag-php","tag-planetscale","tag-vitess"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/794","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=794"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/794\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/793"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=794"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=794"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=794"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}