Tales From A Lazy Fat DBA

Its all about Databases, their performance, troubleshooting & much more …. ¯\_(ツ)_/¯

Archive for July, 2026

Reading MySQL execution plans without guessing

Posted by FatDBA on July 12, 2026

Every DBA hits this situation eventually. A query that ran in 40 milliseconds last month now takes eleven minutes, nobody deployed anything, and the developer swears the table “isn’t even that big.” The instinct is to add an index and hope. That works often enough to be genuinely dangerous, because it teaches you nothing about why it worked, and the next time the same trick will quietly fail.

The alternative is to read what the optimizer is actually telling you. MySQL is not shy about it. It will hand you its plan, its estimates, and since 8.0.18 .. its real measured execution, if you know how to ask.

Everything below was run on MySQL 8.0.46 against a schema I built specifically to misbehave: 200,000 customers and 1,000,000 orders, with a status column that is dangerously skewed.

CREATE TABLE customers (
  id           INT UNSIGNED NOT NULL AUTO_INCREMENT,
  email        VARCHAR(120) NOT NULL,
  country      CHAR(2)      NOT NULL,
  signup_date  DATE         NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uq_email (email),
  KEY ix_country (country)
) ENGINE=InnoDB;

CREATE TABLE orders (
  id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  customer_id  INT UNSIGNED    NOT NULL,
  status       VARCHAR(12)     NOT NULL,
  amount       DECIMAL(10,2)   NOT NULL,
  created_at   DATETIME        NOT NULL,
  PRIMARY KEY (id),
  KEY ix_customer (customer_id),
  KEY ix_status (status)
) ENGINE=InnoDB;



-- The skew is the whole point:

status      rows_actual   pct
shipped        980123    98.012
pending         12043     1.204
cancelled        4876     0.488
refunded         2958     0.296

Now comes the main topic of discussion, How the optimizer picks the plan ?

MySQL’s optimizer is cost-based, and the machinery is simpler than people assume. There’s no plan cache learning from history, no adaptive feedback loop. Every compilation is a fresh search over possible strategies, and the cheapest estimated plan wins. Before it costs anything, MySQL rewrites the query. Constants get folded (price > 10 * 5 becomes price > 50). Constants get propagated — if you write WHERE a.id = 42 AND a.id = b.a_id, MySQL infers b.a_id = 42 and pushes that predicate straight into b. Simple views get merged into the outer query. And — this one bites people constantly .. an outer join gets silently converted to an inner join if the WHERE clause rejects NULLs on the inner table. Write LEFT JOIN orders o ... WHERE o.status = 'shipped' and you have written an inner join, whether you meant to or not, because a NULL o.status can never equal 'shipped'.

Then comes range analysis, and this is the part that matters most for the rest of this post. For each usable index, MySQL doesn’t just consult stored statistics. It performs an actual B tree search .. records_in_range() .. physically walking into the index to count how many rows fall inside your predicate’s range. This is real work, touching real pages, at plan time.

Join ordering is a greedy search, not exhaustive. Two conditions govern it: optimizer_search_depth (default 62) and optimizer_prune_level (default 1). For three or four tables MySQL will find something near optimal. For fifteen tables it will find a plan, and that plan may be mediocre. Counterintuitively, if you’re joining that many tables and getting bad plans, lowering optimizer_search_depth to 4 or 8 often produces better plans faster, because you stop the optimizer from burning its budget on a search space it can’t cover anyway.

Costs are computed from tunable constants in mysql.server_cost and mysql.engine_cost …. row_evaluate_cost, io_block_read_cost, and friends. You can change them. You almost definately shouldn’t.

Next lets talk about plan generation and tools we have –> Three tools, three different jobs, and people conflate them constantly.

EXPLAIN gives you the plan as a flat table. It does not run the query. It’s fast, it’s compact, and it’s what you reach for 90% of the time. EXPLAIN FORMAT=JSON gives you the same plan with everything EXPLAIN had to leave out .. the actual cost numbers, which key parts were used, which columns get read, and the exact condition attached to each table. Still doesn’t run the query. When EXPLAIN says something confusing, this is where you go to find out why. EXPLAIN ANALYZE (8.0.18+) actually executes the query and reports what really happened alongside what was predicted. This is the one that ends arguments. It’s also the one you should think twice about before running against production on a DELETE— though for SELECTs it’s just a query that costs you its own runtime.

Same query, all three.

mysql> EXPLAIN SELECT c.email, o.amount FROM customers c
    ->   JOIN orders o ON o.customer_id = c.id
    ->  WHERE o.status = 'refunded' AND c.country = 'JP'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: o
         type: ref
possible_keys: ix_customer,ix_status
          key: ix_status
      key_len: 50
          ref: const
         rows: 2958
     filtered: 100.00
        Extra: NULL
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: c
         type: eq_ref
possible_keys: PRIMARY,ix_country
          key: PRIMARY
      key_len: 4
          ref: shop.o.customer_id
         rows: 1
     filtered: 5.00
        Extra: Using where



-- JSON format adds the details what were hidden
{
  "query_block": {
    "cost_info": { "query_cost": "3665.19" },
    "nested_loop": [
      { "table": {
          "table_name": "o",
          "access_type": "ref",
          "key": "ix_status",
          "used_key_parts": ["status"],
          "key_length": "50",
          "rows_examined_per_scan": 2958,
          "rows_produced_per_join": 2958,
          "filtered": "100.00",
          "cost_info": { "read_cost": "829.74", "eval_cost": "295.80",
                         "prefix_cost": "1125.54" }
      }},
      { "table": {
          "table_name": "c",
          "access_type": "eq_ref",
          "key": "PRIMARY",
          "key_length": "4",
          "ref": ["shop.o.customer_id"],
          "rows_examined_per_scan": 1,
          "rows_produced_per_join": 147,
          "filtered": "5.00",
          "cost_info": { "prefix_cost": "3665.19" },
          "attached_condition": "(`shop`.`c`.`country` = 'JP')"
      }}
    ]
  }
}

prefix_cost is cumulative … 1125.54 after the first table, 3665.19 after both. rows_produced_per_join: 147 is the estimate flowing out of the join. attached_condition shows you exactly what’s being evaluated where, which is worth the verbosity all by itself. And now comes the truth time 🙂

mysql> EXPLAIN ANALYZE SELECT c.email, o.amount FROM customers c
    ->   JOIN orders o ON o.customer_id = c.id
    ->  WHERE o.status = 'refunded' AND c.country = 'JP'\G

-> Nested loop inner join  (cost=4286 rows=148) (actual time=1.18..37.4 rows=56 loops=1)
    -> Index lookup on o using ix_status (status='refunded')
         (cost=1035 rows=2958) (actual time=0.826..11.5 rows=2958 loops=1)
    -> Filter: (c.country = 'JP')
         (cost=0.999 rows=0.05) (actual time=0.00866..0.00866 rows=0.0189 loops=2958)
        -> Single-row index lookup on c using PRIMARY (id=o.customer_id)
             (cost=0.999 rows=1) (actual time=0.00842..0.00844 rows=1 loops=2958)

Read that inner block carefully, because it’s the single most misread thing in MySQL. rows=0.0189 loops=2958 does not mean MySQL found 0.0189 rows. It means the inner side was executed 2,958 times, and averaged 0.0189 rows per execution. Multiply: 0.0189 × 2958 ≈ 56, which is exactly the final row count. Same for timing … actual time=0.00866 is per loop, not total. The total time for that operation is roughly 0.00866 × 2958 ≈ 25ms. Estimated 148 rows, got 56. That’s a 2.6× overestimate, and I’ll come back to where it came from.

Next, lets check all of the columns and values used in the plan and what exact that means..

id : which SELECT this row belongs to. Same number across rows means same query block, and the order those rows appear in is the join order. Different numbers mean subqueries or unions, and a bigger id executes first.

select_type : SIMPLE for a plain query, PRIMARY for the outer part of one with subqueries, SUBQUERY, DERIVED, UNION, MATERIALIZED. If you see DEPENDENT SUBQUERY, tense up: it means the subquery reruns for every outer row.

table : the table, or a synthetic name like <derived2> for a derived table.

type : The access method. Most important column in the output; full section below.

possible_keys : indexes the optimizer considered. NULL here is diagnostic: it means no index could even theoretically serve this predicate, so before you tune anything, go look at whether the right index exists at all.

key : what it actually chose. When possible_keys lists three indexes and key picks one, the other two lost on cost. That’s a decision you can second-guess.

key_len : bytes of the index actually used. This is the column that catches partial index usage, and people ignore it because the calculation/maths looks like magic, but It isn’t:

  • Each column’s base size (INT = 4, BIGINT = 8)
  • +1 byte if the column is NULLable
  • For variable-length strings, +2 bytes for the length prefix
  • Character columns are counted in bytes, not characters — under utf8mb4 that’s 4 bytes per character

Check it against our real output. status is VARCHAR(12) NOT NULL under utf8mb4: 12 × 4 = 48 octets, no null byte, +2 for the length prefix = 50. And EXPLAIN reports key_len: 50. Exactly. ix_country on CHAR(2) gives 2 × 4 = 8, and yes, key_len: 8.

I built a throwaway table to prove the NULL byte is real …

INT NULL      -> key_len: 5     (4 + 1 null byte)
INT NOT NULL  -> key_len: 4
VARCHAR(10) NULL -> key_len: 43 (10*4 + 1 + 2)

Why care? Because on a composite index (a, b, c), key_len tells you how many of those columns are actually doing work. If key_len only accounts for a, then b and c are dead weight for this query and you’ve been fooling yourself about your index.

ref : what’s being compared against the indexed column. const for a literal, or a column name like shop.o.customer_id when the value comes from an earlier table in the join. This is how you read the data flow of a nested loop.

rows : estimated rows examined for this step. Estimated, and in a join it’s per iteration, not total.

filtered : percentage of those rows expected to survive the WHERE conditions that weren’t handled by the index. This is the column everyone skips and shouldn’t, because rows × filtered / 100 is what actually flows to the next table. In the join above, table c shows rows: 1, filtered: 5.00 … MySQL believes 5% of customers are in Japan. It isn’t 5%. There are eight countries in my data and JP is the rarest at about 2%. That 5.00 is a guess, and it’s precisely why the join estimate came out at 148 instead of 56.

Extra — everything else, and where the real diagnoses live.

Access types, which is best and the worst ??

const : at most one row, matched on a primary key or unique index against a literal. MySQL reads it once during optimization and substitutes it as a constant. You cannot do better.

EXPLAIN SELECT email FROM customers WHERE id = 42;
type: const   key: PRIMARY   key_len: 4   ref: const   rows: 1

eq_ref : one row from this table per row from the previous table, via PK or unique index. This is the ideal for the driven side of a join, and it’s what our join got:

type: eq_ref   key: PRIMARY   key_len: 4   ref: shop.o.customer_id   rows: 1

ref : A non-unique index lookup, may return many rows. Perfectly healthy. Our status='refunded' lookup is ref with rows: 2958.

range : index scan over a bounded range. BETWEEN, >, IN (...), LIKE 'foo%'.

EXPLAIN SELECT id FROM orders WHERE id BETWEEN 100 AND 500;
type: range   key: PRIMARY   key_len: 8   rows: 401   Extra: Using where; Using index

401 estimated, 401 actual. B-tree dives are good at this.

index : full scan of the entire index. Not a lookup. It’s ALL wearing a nicer coat, and it’s only cheap because the index is narrower than the table:

EXPLAIN SELECT COUNT(country) FROM customers;
type: index   key: ix_country   rows: 199488   Extra: Using index

ALL : Is a full table scan. Every row, from disk.

EXPLAIN SELECT * FROM orders WHERE amount > 500;
type: ALL   possible_keys: NULL   key: NULL   rows: 997152   filtered: 33.33

Note: possible_keys: NULL : there’s no index on amount, so this was never even a contest. And note rows: 997152 when the table holds exactly 1,000,000 rows. InnoDB’s row count is itself an estimate, sampled, not counted. Don’t trust it to the digit. A full scan is not automatically a bug, by the way. If you’re reading 98% of the table, a scan genuinely is cheaper than a million index lookups each followed by a random row fetch. The bug is a full scan on a selective predicate.

Now lets read ‘EXTRA’:

Using index : a covering index. Everything the query needs lives in the index; the table itself is never touched. This is the single biggest win available in the Extra column, and it’s easy to engineer. Watch this ..

-- before
EXPLAIN SELECT customer_id, status FROM orders WHERE status='refunded';
type: ref   key: ix_status   rows: 2958   Extra: NULL

ALTER TABLE orders ADD INDEX ix_status_cust (status, customer_id);

-- after
type: ref   key: ix_status_cust   rows: 2958   Extra: Using index

Same rows, same access type, but Extra flipped to Using index and we eliminated 2,958 random primary-key lookups.

Using where : MySQL is filtering rows after fetching them from the storage engine. Not fatal on its own. But Using where sitting next to type: ALL and a low filtered is the classic missing-index signature.

Using index condition : Index Condition Pushdown. Different from Using index, and the names are unhelpfully similar. ICP means the condition is evaluated at the index level so non-matching entries never trigger a row fetch. It’s a real optimization, not a warning.

Using temporary : an internal temp table was built, usually for GROUP BY or DISTINCT. In 8.0 it’s TempTable in memory up to temptable_max_ram (default 1GB), then spills to disk. Small ones are fine. Big ones are why your query fell off a cliff.

Using filesort : the results had to be sorted, because no index provided the required order. “Filesort” does not mean it hit disk; it may well sort entirely in memory within sort_buffer_size. Bad name, causes endless confusion. Still, it’s work you might be able to design away with the right index.

Both at once, which is the combination worth hunting for:

EXPLAIN SELECT c.country, SUM(o.amount) t
  FROM customers c JOIN orders o ON o.customer_id = c.id
 WHERE o.status = 'pending'
 GROUP BY c.country ORDER BY t DESC;

table: o   type: ref   key: ix_status   rows: 22536
Extra: Using temporary; Using filesort

The GROUP BY builds the temp table, and the ORDER BY SUM(o.amount) forces the filesort on top of it .. you cannot index your way out of ordering by an aggregate that doesn’t exist until the group is computed. Sometimes the answer is “this is inherent, accept it.” Knowing which it is, is the job.

Scenario one: the cardinality trap (and where the usual advice is wrong)

Here’s where I have to correct something I’ve written myself in the past.

The standard story goes: MySQL assumes uniform distribution, so on a skewed column it wildly misestimates, and histograms fix it. Let’s test that. Two queries, same column, wildly different selectivity:

-- refunded: 2,958 rows, 0.296% of table
type: ref   key: ix_status   rows: 2958   filtered: 100.00

-- shipped: 980,123 rows, 98% of table
type: ref   key: ix_status   rows: 498576   filtered: 100.00

For refunded, rows: 2958 :: the exact true count. Not close. Exact. So much for uniform-distribution assumptions. This is records_in_range() diving into the B-tree and counting for real. But look at shipped: estimated 498,576, and EXPLAIN ANALYZE reports the truth:

-> Index lookup on orders using ix_status (status='shipped')
   (cost=52812 rows=498576) (actual time=2.35..2136 rows=980123 loops=1)

980,123 actual against 498,576 estimated. A 1.97× underestimate … on the unselective value, not the selective one. The dive samples a bounded number of pages; over a huge range it extrapolates, and extrapolation drifts. So, surely a histogram fixes it?

ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 16 BUCKETS;
-- re-run, WITH histogram in place
type: ref   key: ix_status   rows: 498576   filtered: 100.00

Nothing changed. Identical estimate. This is the finding I did not expect, and it’s the most useful thing in this post: when a usable index exists, the index dive wins and the histogram is ignored for the rows estimate.m Histograms feed filtered, not rows. To see them work, take the index away:

-- ix_status dropped
type: ALL   possible_keys: NULL   rows: 997152   filtered: 0.30   Extra: Using where

-> Filter: (orders.status = 'refunded')  (cost=100912 rows=2949)
   (actual time=0.83..251 rows=2958 loops=1)
    -> Table scan on orders  (cost=100912 rows=997152)
       (actual time=0.738..200 rows=1e+6 loops=1)

filtered: 0.30 against a true 0.296%. Estimated 2,949 rows, actual 2,958. The histogram is superb — but only where the optimizer had nothing better.

The practical rule, then, is the opposite of the folklore. Build histograms on columns you are filtering on but have not indexed … typically because they’re low-cardinality and an index would be pointless, but they still need a decent filtered estimate to drive join ordering. Indexing the column and histogramming it is mostly wasted effort. One more trap while we’re in here. Look at the stored histogram:

"buckets": [
  ["base64:type254:Y2FuY2VsbGVk", 0.004594302009347028],
  ["base64:type254:cGVuZGluZw==",  0.01689858210334539],
  ["base64:type254:cmVmdW5kZWQ=",  0.019855833971430835],
  ["base64:type254:c2hpcHBlZA==",  1.0]
],
"histogram-type": "singleton",
"sampling-rate": 0.07373942163752957

Those are cumulative frequencies, not per-value. refunded is not 1.98% of the table … it’s 0.0199 − 0.0169 = 0.0030, i.e. 0.30%. The per-value frequency is the difference between consecutive buckets. I have watched people read that 1.0 on shipped and conclude the histogram is broken. And while we’re looking at bad statistics, check what InnoDB thinks ix_status contains:

INDEX_NAME    COLUMN_NAME    CARDINALITY
ix_status     status         3
ix_customer   customer_id    197646
PRIMARY       id             997152

Cardinality 3. There are four distinct statuses. The default 20 page sample (innodb_stats_persistent_sample_pages) missed one entirely. If you have a low-cardinality column with a rare value that matters, raise that setting for the table and re-analyze … otherwise the optimizer is reasoning about a value it doesn’t know exists.

Scenario two: join order, and why it’s the whole game

Nested-loop joins have a brutal asymmetry: the driving table’s row count becomes the loop count for everything after it. Get it backwards and you multiply your own pain. MySQL got our join right. It drove from orders (2,958 refunded rows) and probed customers by primary key:

-> Nested loop inner join  (cost=4286 rows=148) (actual time=1.18..37.4 rows=56 loops=1)
    -> Index lookup on o using ix_status (status='refunded')
         (cost=1035 rows=2958) (actual ... rows=2958 loops=1)
    -> Filter: (c.country = 'JP')
         (cost=0.999 rows=0.05) (actual ... rows=0.0189 loops=2958)

2,958 loops. 37.4ms. …. Now let’s force the mistake with STRAIGHT_JOIN, which pins join order to the order you wrote:

-> Nested loop inner join  (cost=7692 rows=202) (actual time=1.2..93.1 rows=56 loops=1)
    -> Index lookup on c using ix_country (country='JP')
         (cost=920 rows=4039) (actual ... rows=4039 loops=1)
    -> Filter: (o.status = 'refunded')
         (cost=1.2 rows=0.05) (actual ... rows=0.0139 loops=4039)
        -> Index lookup on o using ix_customer (customer_id=c.id)
             (cost=1.2 rows=4.79) (actual ... rows=5.03 loops=4039)

4,039 loops. 93.1ms. Identical 56 rows out, two and a half times the time. And the inner side is worse than the loop count suggests: each of those 4,039 loops now fetches ~5 orders and throws nearly all of them away (rows=5.03 in, rows=0.0139 surviving the filter). We’re reading roughly 20,000 order rows to keep 56.

The optimizer picked correctly here. It doesn’t always … and when it doesn’t, the reason is almost always upstream: a filtered percentage built on a guess. Recall that filtered: 5.00 on customers. MySQL had no histogram on country, so it fell back to a canned guess, and that guess is what produced the 148-row estimate against 56 actual. On this query the error was harmless. On a five-table join, an error like that at step one compounds through every subsequent step, and that is how you get a plan that’s wrong by four orders of magnitude. While we’re here: filtered: 33.33 on the amount > 500 scan earlier is the same phenomenon in its purest form. That number is not measured. It’s MySQL’s hardcoded 1/3 guess for a range condition on a column it knows nothing about. Any time you see 33.33, 11.11, or 5.00 in filtered, you are looking at a guess, not a statistic.

Selecting and validating indexes

Start with the predicate, not the query text. Equality columns go first in a composite index, then range columns, then columns needed only for ORDER BY or covering. The reason is mechanical: a B tree can only use one range column before the ordering breaks down for everything to its right.

Check key_len to confirm the index is being used as far as you think it is. If your index is (status, customer_id, created_at) and key_len comes back as 50, only status is doing work.

Aim for Using index in Extra where you can. Adding one column to an existing index to make it covering is often a bigger win than adding a whole new index … and it costs you far less on write throughput.

Then validate with EXPLAIN ANALYZE, and compare estimated against actual. A ratio inside about 10× is usually fine. Beyond that, ask why: stale stats (run ANALYZE TABLE), a sample too small to see your rare values (raise innodb_stats_persistent_sample_pages), or a filtered built on a guess (histogram the column … as long as it isn’t already indexed).

Finally, resist the urge to just add the index. Every index is a write tax on every INSERT, UPDATE, and DELETE against that table, forever. Before you create one, check sys.schema_unused_indexes and sys.schema_redundant_indexes … I have never once run those on a mature production system and not found something to drop.

So, in short, summary, EXPLAIN tells you the plan. FORMAT=JSON tells you the costs behind it. EXPLAIN ANALYZE tells you the truth, and the truth is the only one of the three you can act on with confidence. Read rows and filtered together, always, because rows × filtered / 100 is what actually flows downstream. Treat loops as a multiplier, and remember that rows= and actual time= on an inner node are per loop. And when filtered shows you 33.33 or 5.00, recognise it for what it is: MySQL shrugging.

Then go and check whether the optimizer’s guess matched reality. Usually it does. The eleven-second query is where it didn’t.

Hope It Helped!
Prashant Dixit

Posted in Uncategorized | Tagged: , , , | Leave a Comment »

When MySQL OPTIMIZE TABLE made the file bigger instead of smaller and how old_alter_table solved the issue …

Posted by FatDBA on July 2, 2026

Every DBA has had that moment where a command looks simple on paper, but the database decides to teach you a lesson in production-style humility. For me, this happened during a MySQL InnoDB space reclaim activity. The task looked straightforward. We had a few large InnoDB tables where a lot of data had been deleted or moved over time. The logical data inside the table was much smaller than the physical .ibd file sitting on disk. So the goal was simple: rebuild the table, compact the data, and return unused space back to the filesystem.

In MySQL terms, this usually means running —–> OPTIMIZE TABLE XXXX;

The environment was MySQL Community Server 8.0.43. The tables were InnoDB tables, using innodb_file_per_table, which means each table had its own .ibd file on disk. That part is important because if the table is stored in its own file per table tablespace, rebuilding the table can physically shrink the .ibd file and return unused space back to the operating system … At least, that was the expectation. Before starting, we checked the table size from MySQL and from the operating system. For example, one test table, let’s call it appdata.customer_activity_history, had a physical .ibd file that was much larger than the active data inside the table.

The checks were simple and familiar:

SELECT NAME, SPACE_TYPE, ROW_FORMAT, PAGE_SIZE, ROUND(FILE_SIZE/1024/1024/1024,2) AS FILE_SIZE_GB, ROUND(ALLOCATED_SIZE/1024/1024/1024,2) AS ALLOCATED_SIZE_GB, 
STATE FROM information_schema.INNODB_TABLESPACES WHERE NAME = 'appdata/customer_activity_history';

-- And from the OS side:
du -sh /var/lib/mysql/appdata/customer_activity_history.ibd

The table had a clear gap. The .ibd file was large, and based on the data pattern, we expected a rebuild to reclaim a good amount of space. So we ran the usual command —> OPTIMIZE TABLE appdata.customer_activity_history

The command completed, and MySQL returned the familiar InnoDB message … “Table does not support optimize, doing recreate + analyze instead” .. BTW, this message is normal for InnoDB. It simply means MySQL is not doing a classic MyISAM style optimize. For InnoDB, MySQL translates the operation into a table rebuild and analyze style operation.

mysql> OPTIMIZE TABLE appdata.customer_activity_history;

+----------------------------------+----------+----------+-------------------------------------------------------------------+
| Table                            | Op       | Msg_type | Msg_text                                                          |
+----------------------------------+----------+----------+-------------------------------------------------------------------+
| ppdata.customer_activity_history | optimize | note     | Table does not support optimize, doing recreate + analyze instead |
| ppdata.customer_activity_history | optimize | status   | OK                                                                |
+----------------------------------+----------+----------+-------------------------------------------------------------------+

2 rows in set (3 hours 8 min 33.18 sec)

But then came the surprise. When we checked the .ibd file again, the space was not reclaimed as expected. In some cases, the file looked almost the same. In other cases, it actually increased. That is the kind of thing that makes you stop for a minute.

You run a command to reclaim space, wait for a long table rebuild to finish, and then the file gets bigger. Not exactly the kind of result you want to explain with confidence. The first reaction was to question the obvious things. Was the table really using InnoDB? Was innodb_file_per_table enabled? Was the file we checked really the correct .ibd file? Were there long-running transactions? Was there enough free disk space? Was this table partitioned? Were stats outdated? Was the table still holding more active data than expected?

All of those checks were important. Nothing looked wrong from a basic validation point of view. The table was InnoDB. The table had its own .ibd file. The table was accessible. There was enough disk space. The command completed successfully. But the physical reclaim was still not happening the way we expected.

At this point, the issue looked less like a data problem and more like a rebuild method problem. The fix came from changing how MySQL performed the rebuild. Instead of relying on the default optimized ALTER behavior, we forced MySQL to use the older copy style ALTER method by setting this session parameter before running the optimize command …. old_alter_table

Then we ran the optimize again in the same session:

SET old_alter_table=1;
OPTIMIZE TABLE appdata.customer_activity_history;

This small change made the difference … with old_alter_table=1, MySQL does not use the newer optimized ALTER processing method. It falls back to the older temporary table copy method. In simple words, MySQL creates a new copy of the table, copies the active data into it, rebuilds the indexes, and then renames the rebuilt table back into place. That copy style rebuild gave us the clean physical rebuild we were looking for.

After running the command with the session parameter enabled, we checked the .ibd file again and this time, the file size reduced as expected. That was the “okay, now we are getting somewhere” moment.

But one successful table is not enough. A DBA never trusts one clean result when dealing with large tables and filesystem reclaim. So we repeated the same process on a few more large InnoDB tables and for each table, we followed the same careful pattern. We checked the file size before starting. We checked available disk space. We made sure there were no obvious long-running transactions or blockers. We ran SET old_alter_table=1; in the same MySQL session. Then we ran OPTIMIZE TABLE. After it completed, we checked the .ibd file again from the OS level and validated that the table was still accessible.

The important part was not just that the command completed. The important part was that the physical file size actually reduced after the operation. That confirmed the workaround was repeatable. One detail that should not be missed is that old_alter_table is a session-level setting when set this way. It is not something you run once in one window and then assume it applies everywhere forever. If the MySQL session disconnects, or if you open a new MySQL session, you need to run it again before the next OPTIMIZE TABLE.

Another important point is disk space. This type of rebuild can temporarily require extra space because MySQL may need space for the rebuilt copy while the operation is running. So even though the final result may be a smaller .ibd file, you still need enough free filesystem space before starting. This is especially important when working with hundreds of GBs or TB-sized tables. This is also not something I would run blindly across many tables in parallel. Large InnoDB rebuilds can be heavy. They can consume I/O, generate temporary space usage, affect replication lag if replication is involved, and introduce metadata locking during parts of the operation. The more controlled approach is to run one table at a time, validate the result, then move to the next one.

The other lesson here is about expectations. OPTIMIZE TABLE sounds like a simple maintenance command, but with InnoDB it is really a table rebuild operation. And with large InnoDB tables, the rebuild method matters. The command can complete successfully from MySQL’s point of view, but that does not always mean the physical reclaim result will match what you expected. In our case, the standard optimize path did not reclaim space correctly and even increased the .ibd file size in some cases. After we added the session command SET old_alter_table=1; immediately before OPTIMIZE TABLE, MySQL used the copy-style rebuild method, and the .ibd file started shrinking as expected. The final version of the approach was simple ..

-- In the session run below param 
SET old_alter_table=1;

-- Now all set to run the optimize command and to avoid the issue
OPTIMIZE TABLE appdata.customer_activity_history;

-- Then validate from the OS:
du -sh /var/lib/mysql/appdata/customer_activity_history.ibd

-- And validate from MySQL:
SELECT
    table_schema,
    table_name,
    ROUND((data_length + index_length)/1024/1024/1024, 2) AS logical_gb,
    table_rows
FROM information_schema.tables
WHERE table_schema = 'appdata'
  AND table_name = 'customer_activity_history';

So if you ever run OPTIMIZE TABLE on a large MySQL InnoDB table and the .ibd file does not shrink, or worse, it grows, do not panic immediately. First, verify the basics. Confirm the storage engine, confirm innodb_file_per_table, check the physical file, check the logical size, and make sure you have enough disk space. It is a small setting, but in this case, it made a big difference.

And yes, sometimes the most useful DBA lessons start with a command doing the exact opposite of what you expected.Then, if the default rebuild path is not giving the expected reclaim, test the session-level copy-style rebuild method carefully

Hope It Helped!
Prashant Dixit

Posted in Uncategorized | Tagged: , , , , | Leave a Comment »

Using pathMappingTemplate and fileNameMappingTemplate in Oracle GoldenGate 23ai DAA

Posted by FatDBA on July 1, 2026

One interesting issue we hit during one of the GoldenGate DAA to Microsoft Fabric Lakehouse testing was not really a replication failure. GoldenGate was running fine, the Replicat was not abended, the trail was being read, and stats were showing that records were getting processed. So from a GoldenGate side, everything looked healthy. But when we checked the Fabric Lakehouse, the files were not showing up where we expected them.

That made the issue a little tricky at first. Usually, when something is wrong with GoldenGate, we expect to see an abend, an authentication error, a trail read issue, or something obvious in the report file. In this case, GoldenGate was doing its job. The problem was that the output path being used by the OneLake handler was not the folder structure the team wanted. The target was Microsoft Fabric Lakehouse, and the GoldenGate property file had the usual OneLake entries: workspace, lakehouse, tenant ID, OneLake endpoint, app registration client ID/secret, classpath, and datatype mapping options. Something like this:

gg.target=fabric_lakehouse

gg.eventhandler.onelake.workspace=<workspace_name>
gg.eventhandler.onelake.lakehouse=<lakehouse_name>
gg.eventhandler.onelake.tenantId=<tenant_id>
gg.eventhandler.onelake.endpoint=https://onelake.dfs.fabric.microsoft.com

gg.eventhandler.onelake.clientId=<client_id>
gg.eventhandler.onelake.clientSecret=<client_secret>

gg.handler.onelake.format.enableDecimalLogicalType=true
gg.handler.onelake.format.mapOracleNumbersAsStrings=false
gg.handler.onelake.format.mapLargeNumbersAsStrings=false
gg.handler.onelake.format.enableTimestampLogicalType=true
gg.format.timestamp=yyyy-MM-dd HH:mm:ss.SSS

With this basic configuration, files were getting created, but GoldenGate was using its own default folder pattern. That default structure was not what the downstream team wanted to consume. The expectation was simple. We wanted the files to go under a clean Lakehouse folder, something like:

Files/<business-folder>/<schema>/<table>

But the actual folder structure was coming out differently. At one point, we saw folder names with .schema in them, like:

Files/<business-folder>/<schema>.schema/<table>

At first, it was easy to think this might be a Fabric artifact or something automatically added by GoldenGate. But after checking the property file carefully, we realized the .schema suffix was coming from our own path mapping template. The property had been set like this:

gg.eventhandler.onelake.pathMappingTemplate=${catalogname}.lakehouse/Files/<business-folder>/${schemaname}.schema/${tablename}

So GoldenGate was doing exactly what we told it to do. Because the template had ${schemaname}.schema, it created folders with .schema in the name. The fix was simple once we identified it. We removed .schema from the template:

gg.eventhandler.onelake.pathMappingTemplate=${catalogname}.lakehouse/Files/<business-folder>/${schemaname}/${tablename}

After this change, the Lakehouse folder structure became much cleaner … Files/<business-folder>/<schema>/<table> and that solved the issue.

The second feedback was around parquet file names. The files were being generated with the GoldenGate process name as a prefix. That can be useful in some cases, but here the team wanted a cleaner name based only on schema, table, and timestamp. So instead of file names like this —> <replicat_name>_<schema>.<table>_<timestamp>.parquet and the expected format was —> <schema>.<table>_<timestamp>.parquet

To control that, I’ve added this property:

gg.eventhandler.onelake.fileNameMappingTemplate=${schemaname}.${tablename}_${currentTimestamp[yyyy-MM-dd_HH-mm-ss.SSS]}.parquet

With that, the file names became much easier to understand from the Fabric side. The final important part of the property file looked like this:

gg.eventhandler.onelake.pathMappingTemplate=${catalogname}.lakehouse/Files/<business-folder>/${schemaname}/${tablename}
gg.eventhandler.onelake.fileNameMappingTemplate=${schemaname}.${tablename}_${currentTimestamp[yyyy-MM-dd_HH-mm-ss.SSS]}.parquet

Once those changes were made, we restarted the target process. For a normal CDC flow, a stop/start would be enough for new files to follow the new format. But because this was a test and we wanted to validate the full output from the beginning, we cleaned the old test folder in Lakehouse and replayed the trail from the start. That is an important point. If you are testing folder or file naming changes and you do not clean the previous output, it can become confusing very quickly. Old files and new files can sit together, and then it becomes hard to tell which setting produced which folder or file.

The way we diagnosed this was also a good reminder. We did not start by changing random properties. First, we confirmed GoldenGate was actually processing data. Then we checked the target Lakehouse. Since files were being created, we knew authentication and basic OneLake writing were working. That narrowed the issue down to path and file naming.

In the end, this was not a GoldenGate failure. It was a configuration alignment issue between GoldenGate’s OneLake handler and the folder structure expected by the Fabric team. The main lesson is: when GoldenGate DAA is writing to Fabric Lakehouse and files are landing in the wrong place, check these two properties first

gg.eventhandler.onelake.pathMappingTemplate and gg.eventhandler.onelake.fileNameMappingTemplate

Small strings inside those templates make a big difference. If the template contains .schema, the folder will contain .schema. If the file name template includes the process name, the file will include the process name. GoldenGate is very literal here.

Once we controlled both the path and file name templates, the output started landing exactly where we wanted it in the Lakehouse, with a much cleaner structure for the downstream team.

Hope It Helped!
Prashant Dixit

Posted in Uncategorized | Tagged: , , , , , , , | Leave a Comment »