Quick Answer: MySQL 8 beat MariaDB 11.4 on JSON search in my 4M-row order test. With a generated column and a B-tree index, MySQL returned filtered results at 41ms P95; MariaDB needed 128ms P95 for the same query. The 3x gap came from MySQL's binary JSON storage and the optimizer's use of the generated-column index.
Key Takeaways
- Index the extracted value: Searching
JSON_EXTRACT(metadata, '$.city')directly forces a scan. A generated column plus an index turns it into a normal B-tree lookup. - MySQL stores JSON as binary: MySQL 8 parses JSON into a binary format, which makes extraction and comparison cheaper than MariaDB's text-based storage.
- MariaDB is not slow by default: It can match MySQL on simple lookups when the generated column is indexed correctly. The gap shows up on multi-filter queries and sorting.
- Normalize what you query often: If a JSON field appears in every
WHEREclause, it belongs in a real column. JSON is for the long tail, not the hot path.
The Test Setup
I used two identical 4 vCPU, 8 GB VPS instances and the same 4M-row order dataset. Each row had an id, created_at, total_inr, and a metadata JSON column with city, payment method, coupon code, and device.
| Parameter | Value |
|---|---|
| Rows | 4,000,000 |
| JSON size | 180–420 bytes per row |
| MySQL | 8.0.36, InnoDB |
| MariaDB | 11.4, InnoDB |
| Query | city + payment method + date range, sorted by created_at |
| Concurrency | 20 clients |
| Warm cache | Yes, after 3 runs |
The query is deliberately realistic. It filters two JSON fields and a date range, then sorts. That is what an order dashboard does all day.
Why the Direct JSON Query Is Slow
The naive query looks harmless. It is a full scan with a JSON parse per row.
-- Slow on both engines
SELECT id, total_inr, created_at
FROM orders
WHERE JSON_EXTRACT(metadata, '$.city') = 'Surat'
AND JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.payment')) = 'upi'
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 50;
On 4M rows, MySQL took 1.9s P95 and MariaDB took 2.4s P95. Both are unusable for a dashboard. The fix is the same on both engines: extract the hot fields into generated columns and index them.
The Schema That Makes It Fast
-- MySQL 8 migration
ALTER TABLE orders
ADD COLUMN city VARCHAR(64)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.city'))) STORED,
ADD COLUMN payment VARCHAR(32)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.payment'))) STORED,
ADD COLUMN ordered_on DATE
GENERATED ALWAYS AS (DATE(created_at)) STORED;
CREATE INDEX idx_orders_city_payment_date
ON orders (city, payment, ordered_on, created_at);
-- MariaDB 11.4 migration
ALTER TABLE orders
ADD COLUMN city VARCHAR(64)
GENERATED ALWAYS AS (JSON_VALUE(metadata, '$.city')) VIRTUAL,
ADD COLUMN payment VARCHAR(32)
GENERATED ALWAYS AS (JSON_VALUE(metadata, '$.payment')) VIRTUAL,
ADD COLUMN ordered_on DATE
GENERATED ALWAYS AS (DATE(created_at)) VIRTUAL;
CREATE INDEX idx_orders_city_payment_date
ON orders (city, payment, ordered_on, created_at);
MySQL uses JSON_UNQUOTE(JSON_EXTRACT(...)); MariaDB uses JSON_VALUE(...). Both support virtual or stored generated columns. I use STORED on MySQL for the city and payment fields because they are read constantly and written rarely. On MariaDB, VIRTUAL with an index is fine for this workload.
How the Query Planner Uses the Index
flowchart LR
A[WHERE city + payment + date] --> B{Generated columns exist?}
B -- No --> C[Full scan + JSON parse]
B -- Yes --> D[Composite B-tree index]
D --> E[Index range scan]
E --> F[Sort by created_at]
F --> G[Return 50 rows]
C --> H[1.9s to 2.4s]
G --> I[18ms to 128ms]
The Results
| Query | MySQL 8 P95 | MariaDB 11.4 P95 | Winner |
|---|---|---|---|
| Direct JSON extract | 1,900 ms | 2,400 ms | MySQL |
| Generated column, single filter | 18 ms | 27 ms | MySQL |
| Generated column, two filters + sort | 41 ms | 128 ms | MySQL |
| JSON_TABLE explode, 3 keys | 220 ms | 190 ms | MariaDB |
| Index size | 1.4 GB | 1.6 GB | MySQL |
| Write overhead | 8% | 11% | MySQL |
MariaDB won the JSON_TABLE explode case because its implementation handles wide rows with less memory pressure. That is a real use case if you need to unnest an array into rows. For the common dashboard filter, MySQL won by a wide margin.
The Laravel 12 Implementation
Laravel's query builder can use the generated columns like normal columns. The migration keeps the JSON source and the indexed projections in sync automatically.
<?php
// database/migrations/2026_10_27_000000_add_order_search_columns.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::statement("
ALTER TABLE orders
ADD COLUMN city VARCHAR(64)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.city'))) STORED,
ADD COLUMN payment VARCHAR(32)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.payment'))) STORED
");
Schema::table('orders', function (Blueprint $table) {
$table->index(['city', 'payment', 'created_at'], 'idx_orders_search');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex('idx_orders_search');
$table->dropColumn(['city', 'payment']);
});
}
};
// app/Http/Controllers/OrderSearchController.php
use App\Models\Order;
use Illuminate\Http\Request;
public function index(Request $request)
{
$orders = Order::query()
->where('city', $request->string('city'))
->where('payment', $request->string('payment'))
->where('created_at', '>=', $request->date('from'))
->orderByDesc('created_at')
->paginate(50);
return response()->json($orders);
}
The controller does not call JSON_EXTRACT. It filters on indexed columns, so the optimizer can use the composite index. That is the entire performance story.
War Story: The Rajkot Dashboard That Timed Out
A Rajkot distributor ran an order dashboard on MariaDB. The page timed out at 30 seconds whenever the date range crossed a festival month. The team had added an index on created_at, but the JSON filters still forced a scan of every row in the range.
I added the generated columns and a composite index. The query dropped to 120ms P95 on MariaDB, which was enough to keep the dashboard usable. Six weeks later, the client migrated to MySQL for an unrelated reason, and the same query ran at 39ms P95. The schema did most of the work; the engine choice added the last 3x.
I shipped that migration in a maintenance window with a backfill that ran in batches of 5,000 rows. The whole change took 40 minutes on 4M rows. The dashboard has not timed out since.
When NOT to Use JSON Search
Do not add generated columns for fields you query once a month. The write overhead and index size are not worth it. Use a direct query and accept the scan.
Do not use JSON for fields that need foreign keys, unique constraints, or frequent joins. Those belong in normalized columns from day one.
Do not use MariaDB's JSON_TABLE for a simple filter. It is powerful for unnesting arrays, but it adds planning overhead that a generated column avoids.
Do not migrate an engine just for a JSON query. Fix the schema first. In my test, the generated column took MariaDB from 2.4s to 128ms. That is a bigger win than switching databases.
FAQ
Frequently Asked Questions
Is MySQL faster than MariaDB for JSON queries?
For filtered JSON searches on large tables, MySQL 8 was about 3x faster in my 4M-row test, 41ms versus 128ms P95 with generated columns. MariaDB was faster on JSON_TABLE array unnesting. The result depends on the query shape, not the brand.
How do I index a JSON column in MySQL 8?
Create a generated column that extracts the value, then index that column. For example, add city VARCHAR(64) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.city'))) STORED and create an index on city. Queries must filter on city, not on the JSON expression.
Should I use JSON or a normalized table in Laravel?
Use normalized columns for anything you filter, sort, join, or validate often. Use JSON for optional metadata, webhook payloads, and fields that change shape per vendor. A generated column is a bridge, not a replacement for schema design.
How much does a generated column slow down writes?
In my test, MySQL writes were 8% slower and MariaDB writes were 11% slower after adding three generated columns and one composite index. That is acceptable for a read-heavy order table. Measure your own write ratio before applying it to a high-ingest table.
Bottom Line
Index the value you filter, not the JSON document. MySQL 8 wins the dashboard query in my test, but the generated column is what turns a 2.4-second scan into a 128ms lookup on MariaDB. If you are weighing engines for a Laravel app, read the MariaDB vector search notes, the pgvector semantic search guide, and why we ditched a vector SaaS for pgvector. If your dashboard still scans 4M rows, send me the query and I will show you the index it needs.