Tales From A Lazy Fat DBA

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

Archive for July, 2026

Making vector embeddings on the fly with the new AI Service in GoldenGate 26ai

Posted by FatDBA on July 28, 2026

For most of its life, GoldenGate has done one job and done it well, I really like the product 🙂 … moves rows from A to B, fast, without dropping a single change. Capture, pump, apply. That was the whole deal. If you wanted to do something smart with that data on the way (turn it into vectors, run it through a model), that was somebody else’s job further down the line. Usually it meant a separate app, a queue, and a pile of custom code nobody wanted to own on a weekend or friday evening 😀

That has changed in GoldenGate 26ai. Starting with Release Update 23.26.1.0.0, there is a real AI service built into the deployment, and the first thing it lets you do is make vector embeddings right inside the Replicat process. No outside app. No separate batch job that runs at 2 AM and is always behind. You map a text column to an embedding model, and GoldenGate writes the vector into your target as part of the same apply that writes the rest of the row.

I’ve seen enough people bolt embedding pipelines onto the side of a replication stream to know how much work this removes. So let me walk through what the feature actually is, how it’s wired, and what a working Replicat mapping looks like.

Everyone is chasing semantic search and RAG right now. All of it runs on vector embeddings, which are just numbers that stand in for the meaning of some text instead of its exact spelling. The hard part was always when and where you make those vectors. The old way looked like this … you’d test the embedding model from the OCI console or an API client, confirm it works, then build a proper backend app that reads rows, calls the model, and writes the vectors back. It works, but now you own a whole app … its scaling, its failures, and the constant lag between a row changing and its embedding catching up. Every insert on the source becomes a future embedding job somewhere else.

GoldenGate 26ai’s answer is simple: stop treating embedding as a downstream problem. GoldenGate already touches every changed row in real time, so it just does the embedding right there … Yeah!!! \,,/

There are really only two new things to learn — The first is the AI Service itself, a new microservice in your 26ai deployment. If you list processes on the box, you’ll see it running:

[oracle@fatdba1 bin]$ ps -ef | grep AIService
oracle   60905 60721  0 20:50 ?  00:00:00 /u01/app/oracle/product/ogg26ai/bin/AIService

Its job is to hold the connections out to your embedding models (local or remote) and hand back vectors when Replicat asks. Right now it does embeddings. Oracle has said this same service will later carry LLM features too (things like finding names or PII in the data, natural-language admin, and so on). For now, just think of it as GoldenGate’s own middleman to the model providers.

The second piece is a new Replicat function, @AISERVICE. You call it inside a COLMAP to say: take this text column, run it through this model, store the vector in this target column … The providers supported at launch are OCI Generative AI, OpenAI, Google Gemini, and Voyage AI, plus custom setups over ONNX or OpenAI-style APIs. The bring-your-own-model part is real, which matters if you run a local model because customer text isn’t allowed to leave your network.

Almost all the setup lives in Service Manager, and the order matters. There are four things to get right before a single embedding gets made.

1. Proxy first. Your GoldenGate box is almost certainly inside a corporate network with no direct route to the internet, and the model providers live outside that wall. So you point GoldenGate at your outbound proxy with HTTP_PROXY and NO_PROXY. Nothing fancy, but if you skip it, every call to OpenAI or OCI just times out, and you’ll spend an afternoon blaming certificates when it was routing all along.

2. Certificates second. Because these are secure (TLS) calls out to the providers, GoldenGate needs the CA certificate for whichever one you use. The clean way to grab it is straight from the endpoint with OpenSSL ..

# For OpenAI
openssl s_client -showcerts -connect api.openai.com:443

# For OCI Generative AI (Chicago region shown; use your own region's endpoint)
openssl s_client -showcerts -connect inference.generativeai.us-chicago-1.oci.oraclecloud.com:443

Pull the certs out of that output and load them into the Service Manager certificate store. This is the step people most often trip on, usually because they forget the AI endpoint’s CA chain is not the same as the certs they already trust for their database connections.

3. Provider and model third. In Service Manager, under the AI section, you set up the provider and the model. For a simple provider like OpenAI you just give the Base URL and an API key. For OCI Generative AI there’s more, because OCI login is OCI login: the Base URL, the API key, and the cloud identity bits (tenancy OCID, compartment OCID, user OCID, and the key fingerprint). Then in the Model section you name the actual embedding model, for example text-embedding-3-small on OpenAI, or cohere.embed-english-v3.0 on OCI. That model name is what you’ll type into the Replicat later.

4. Check it’s live fourth. Before you touch a Replicat, confirm two things. One, the AI Service shows as enabled and running under the Services section of Service Manager. Two, the model is visible from your user deployment, not just the Service Manager deployment. Those are two different deployments, and the model has to show up in the one your Replicat actually runs in. This catches people out constantly. Check it now and save yourself the head-scratching later.

Here’s where it pays off, and it’s almost too easy. You just add the embedding to the COLMAP of your normal MAP statement… Say you have a source table PARKS with a PARK_ID and a DESCRIPTION column holding text. Your target table PARKS_AI has the same columns plus a new one, DESC_VECTOR, defined as a VECTOR. You want everything to copy across as normal, and DESC_VECTOR to hold the embedding of DESCRIPTION :

MAP source.PARKS, TARGET target.PARKS_AI,
COLMAP (USEDEFAULTS,
    DESC_VECTOR = @AISERVICE(embed, 'cohere.embed-english-v3.0', DESCRIPTION)
);

That’s the whole trick. USEDEFAULTS copies PARK_ID and DESCRIPTION across as-is. The one extra line says: take DESCRIPTION, send it to the cohere.embed-english-v3.0 model through the AI Service, and store the vector it returns in DESC_VECTOR. As each change flows through Replicat, the embedding is made right there and saved with the rest of the row. Source and target stay in step, and there’s no separate job to fall behind, watch, or restart. The nice bit of flexibility: because the model is just an argument to the function, you can send the same source table to two targets using two different providers. One Replicat on OCI, another on OpenAI, same data:

-- Replicat 1: embed using OCI GenAI (Cohere model)
MAP source.DOCS, TARGET lakehouse.DOCS_OCI,
COLMAP (USEDEFAULTS,
    CONTENT_VEC = @AISERVICE(embed, 'cohere.embed-english-v3.0', CONTENT)
);

-- Replicat 2: embed the same text using OpenAI
MAP source.DOCS, TARGET lakehouse.DOCS_OPENAI,
COLMAP (USEDEFAULTS,
    CONTENT_VEC = @AISERVICE(embed, 'text-embedding-3-small', CONTENT)
);

Same text in, two different embedding models out. If you’ve ever had to compare one embedding model against another on live production data, you know that’s normally a small project. Here it’s two lines of parameter file.

These are the things that will actually cause issues, so I’m calling them out.

  • The target vector column must already exist before you reference it. Oracle’s reference guide is clear on this: the embedding column has to be added to the target table first, then you can point @AISERVICE at it in the Replicat. If you write the mapping before the column exists, it won’t work. So the order is: add the VECTOR column to the target, then write the mapping.
  • In 23.26.1, @AISERVICE takes one column only, not an expression. You cannot do something like first_name || ' ' || title inside the function. As of this release it expects a single input column, full stop. In real life you often want a richer text payload (name plus title plus department, say) to get a more useful vector, so plan for that. If you need combined text, build that combined value into a column upstream (or as a virtual/derived column) and point @AISERVICE at that single column instead.
  • The target column has to actually be a VECTOR type. Don’t try to shove embeddings into a CLOB and hope. In practice this means a database that supports the vector datatype, with Oracle AI Database 26ai (or 23ai) with Vector Search being the obvious home. Define the column as VECTOR properly and you’re fine.

You’re making a network call out to the model for every row that hits the mapping. That’s real time and, on a paid API, real money per call. For a steady stream of OLTP changes that’s usually fine. But if you’re doing a huge first-time load of a hundred-million-row table, stop and think about volume, speed, and your provider’s rate limits before you turn it on. Embeddings made in real time are great. Embeddings made in real time across ten million rows during a Monday morning reload can surprise you, both on the clock and on the bill. …. And remember the two-deployment gotcha from setup, because it’s usually the first error you’ll hit: Service Manager holds the provider and certificate config, but the model has to be visible in the user deployment where your Replicat runs. When @AISERVICE complains about an unknown model, that mismatch is the first place to look.

Vector embeddings are the headline, but they’re clearly just the start. Oracle has set up the AI Service as the one microservice that all future AI features will run through. The roadmap they’ve published talks about spotting names and PII in the data as it moves, natural language admin of GoldenGate itself, and agent style APIs like MCP. Whether all of that lands on time is a separate question, but the direction is clear: GoldenGate is moving from a pure change-capture engine into something that also understands and enriches the data it carries.

For those of us who’ve spent years keeping replication lag under control and Extract processes healthy, that’s a genuinely different job description. The good news is that the first thing they shipped, inline embeddings, is small, well-scoped, and solves a real problem people have today. You can turn it on with a couple of lines in a COLMAP and delete a whole downstream pipeline in the process. That’s the rare kind of feature that makes your setup simpler instead of adding one more thing to babysit ….. If you’re already on 23ai, getting here is just the January bundle patch (23.26.x), not a migration, so there’s very little between you and trying it. Set up a provider, point a test Replicat at a text column, and watch vectors show up in the target as the rows land. It’s a good afternoon’s experiment, and it’ll change how you think about what GoldenGate is actually for.

Hope It Helped!
Prashant Dixit

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

Classic to Microservices – what actually changes when you migrate GoldenGate

Posted by FatDBA on July 26, 2026

I’ve been putting off writing this one for a while, mostly because every time I sit down to do it I remember another edge case that bit me on some engagement. But we’ve now done enough of these migrations at client sites that the pattern is stable enough to write down.

Quick framing before we start. Classic Architecture …the one with GGSCI, a Manager process, and the whole thing driven by parameter files you edit with EDIT PARAMS … is gone in the latest release. GoldenGate 23ai is delivered for the Microservices Architecture only; 23ai no longer supports Classic Architecture at all. So this isn’t really a “should we migrate” conversation anymore. It’s a “when and how” conversation. And the timing pressure is real — Premier Support for GoldenGate 19c and 21c is ending in April 2026, so a lot of shops are up against it.

One correction to a thing people often get muddled: Classic Extract and Classic Architecture are two different deprecations. Classic Extract (as opposed to Integrated Extract) was deprecated in 18c and desupported in 21c. Classic Architecture … the whole GGSCI/Manager control plane … is what’s removed in 23ai. If you’re still running a Classic Extract, you have to move to Integrated Extract before you migrate architecture; the migration utility will simply fail if a classic extract is configured

he thing people get wrong is assuming this is a rewrite. It isn’t. Your Extract and Replicat processes do the same work, read the same redo, apply the same DML. What changes is the control plane around them.

In Classic you had a Manager process listening on a port, GGSCI as your shell, and everything living under one $OGG_HOME that mixed binaries with runtime data. Microservices splits this apart:

  • ServiceManager : supervises the deployment
  • Administration Service : replaces Manager and GGSCI’s admin functions
  • Distribution Service : replaces the pump Extract for sending trails downstream
  • Receiver Service .. receives distribution paths on the target side
  • Performance Metrics Service : metrics collection

And critically, binaries now live in OGG_HOME while runtime config, trails, and reports live in a separate deployment directory. That separation is genuinely nice once you get used to it … deployments can just point at a new OGG_HOME, which makes upgrades much less scary. You also get adminclient instead of GGSCI (the GGSCI-equivalent tool is still available as the Admin Client), a REST API for everything, and a web UI. The parameter file syntax itself is almost entirely unchanged, which is the good news.

One replicat note while we’re here, because it caught a colleague out: in 23ai, Integrated Replicat cannot read an EXTFILE … only non-integrated (classic-mode) Replicat can, and for OLTP workloads Oracle recommends Parallel Replicat in non-integrated mode. Keep that in mind if your config reads files rather than trails.

There are genuinely two ways to do this, and you should pick deliberately.

Path A :: the Migration Utility. Oracle ships a dedicated tool. It’s available on My Oracle Support as Patch 37274898 (KB100447), it’s interactive, and it migrates Extracts and Replicats from a Classic deployment to a Microservices deployment, converts pumps to a distribution path, and also migrates trail files, checkpoints, and associated artifacts. That “converts pumps to a distribution path automatically” bit is the big selling point — the pump conversion is the fiddliest part to do by hand. Prerequisites are mentiopned below.

  • GoldenGate 19c or higher with Bundle Patch 200714 or later, and Java 1.8 JRE or later (the one shipped with GoldenGate works).
  • A new Microservices deployment already created with all services running, local to the Classic deployment (or the Classic home filesystem available on the same host).
  • The Classic deployment must be stopped during migration, including any server collectors; abended processes must be restarted and shut down gracefully first.
  • Run the utility from a shell with the environment variables set for the Microservices deployment.

The invocation is a Java jar with a dry-run mode. Set OGG_HOME and LD_LIBRARY_PATH to the 23ai home first, then .. Note: Always run -dryrun first and read every line of what it says it will migrate.

export OGG_HOME=/u01/app/oracle/product/ogg23ai
export LD_LIBRARY_PATH=$OGG_HOME/lib
java -jar ogg-migration-package-23.4.0.0.0.jar \
     -classicHome /u01/app/oracle/product/ogg19 -dryrun

Path B : rebuild by hand. The reason I still reach for this on some engagements: a rebuild forces you to look at every parameter file. I’ve never done one of these without finding a parameter sitting there since 12c doing nothing, or a table in a wildcard that should’ve been excluded three years ago. The utility faithfully preserves your accumulated cruft. If you have twelve well-documented Extracts, use the utility. If you have three and a suspicion nobody’s audited them lately, rebuild.

The rest of this post covers the manual path, because even if you use the utility you should understand what it’s doing under the hood. Before you touch anything, verify the source is clean. On the Classic side:

GGSCI> INFO ALL
GGSCI> SEND EXTRACT ext1, STATUS
GGSCI> LAG EXTRACT ext1
GGSCI> LAG REPLICAT rep1

You want Extract at EOF on redo, Replicat with zero lag and no pending transactions. Anything else and you’re migrating a mess. Then record checkpoint positions. People rush this and regret it:

GGSCI> INFO EXTRACT ext1, SHOWCH
GGSCI> INFO REPLICAT rep1, SHOWCH

Write these down somewhere outside the server … the Extract’s Recovery Checkpoint SCN especially. I once watched a team lose a checkpoint during a rushed cutover and end up repositioning from an SCN that only existed in a terminal scrollback someone had already closed.

Your checkpoint table and GoldenGate DB user carry over fine … the checkpoint table format is compatible, and DBMS_GOLDENGATE_AUTH.GRANT_ADMIN_PRIVILEGE grants don’t need redoing.

Installing Microservices alongside : Install to a new OGG_HOME. Do not install over Classic … you want both available during transition so rollback is real, not theoretical. And per Oracle’s own prereq, the new MA deployment needs to be on the same host (or the Classic home reachable there).

cd /stage/ogg23ai
./runInstaller -silent -responseFile /stage/ogg_install.rsp

Then create the deployment with the config assistant, oggca.sh, which has a silent mode I prefer for repeatability … along with some of the key oggca response entries:

export OGG_HOME=/u01/app/ogg/23ai
$OGG_HOME/bin/oggca.sh -silent -responseFile /stage/oggca.rsp

-- some of the key entries 
CONFIGURATION_OPTION=ADD
DEPLOYMENT_NAME=ogg_prod
ADMINISTRATOR_USER=oggadmin
HOST_SERVICEMANAGER=dbhost01.example.com
PORT_SERVICEMANAGER=9000
SECURITY_ENABLED=false
OGG_SOFTWARE_HOME=/u01/app/ogg/23ai
OGG_DEPLOYMENT_HOME=/u01/app/ogg/deployments/ogg_prod
OGG_SCHEMA=ggadmin
PORT_ADMINSRVR=9001
PORT_DISTSRVR=9002
PORT_RCVRSRVR=9003
PORT_PMSRVR=9004

I’ve set SECURITY_ENABLED=false for readability. In production, don’t … turn on TLS and point it at a proper wallet. The extra hour isn’t worth having your REST API sitting in the clear.

Credentials: Classic used a credential store under $OGG_HOME/dircrd; Microservices has its own per deployment. Just recreate them … cleaner than copying wallet files … One important note is that the CONNECT … you’re connecting to the Administration Service over HTTP, not opening a local shell.

$OGG_HOME/bin/adminclient
OGG> CONNECT http://dbhost01:9001 AS oggadmin PASSWORD ******
OGG> ALTER CREDENTIALSTORE ADD USER ggadmin@ORCLPDB1 PASSWORD ****** ALIAS ggsrc DOMAIN OracleGoldenGate
OGG> INFO CREDENTIALSTORE

Migrating Parameter Files: Mostly copy-paste. Two differences. The DOMAIN clause .. MA credential stores are domain-scoped, and OracleGoldenGate is the default domain. And the trail: you give just the two-character prefix, resolved relative to the deployment’s dirdat. Register and add it, positioning at the checkpoint SCN you recorded (not BEGIN NOW):

-- Classic 
EXTRACT ext1
USERIDALIAS ggsrc
EXTTRAIL ./dirdat/ea
TABLE SALES.ORDERS;

-- Microservices
EXTRACT ext1
USERIDALIAS ggsrc DOMAIN OracleGoldenGate
EXTTRAIL ea
TABLE SALES.ORDERS;

-- Register and add it, positioning at the checkpoint SCN you recorded (do not BEGIN NOW)
OGG> DBLOGIN USERIDALIAS ggsrc DOMAIN OracleGoldenGate
OGG> REGISTER EXTRACT ext1 DATABASE CONTAINER (ORCLPDB1)
OGG> ADD EXTRACT ext1, INTEGRATED TRANLOG, SCN 45892017334
OGG> ADD EXTTRAIL ea, EXTRACT ext1, MEGABYTES 500

One accuracy note specific to 23ai targets: Per-PDB Extract is the only supported Extract for Oracle Database 23ai, except in a DownStream Capture configuration, where you use a root-level Extract and must set the Streams Pool. That doesn’t change a 19c-source migration, but if your database is also moving to 23ai, plan the Extract mode accordingly. For Replicat, this is a good moment to move to Parallel Replicat:

OGG> DBLOGIN USERIDALIAS ggtgt DOMAIN OracleGoldenGate
OGG> ADD REPLICAT rep1, PARALLEL, EXTTRAIL ra, CHECKPOINTTABLE ggadmin.ggs_checkpoint

The PUMP replacement : Biggest conceptual change. The Classic data pump … a secondary Extract with RMTHOST/RMTTRAIL pushing to the target Manager … becomes a Distribution Path in the Distribution Service, with no parameter file. On authentication, be aware there’s a real choice here: Microservices offers USERIDALIAS target authentication (an Operator-role user on the target whose credentials are stored on the source; the path’s target auth method is set to Password and it uses the WSS secure web socket protocol) or certificate-based authentication using trusted CA certificates. Via adminclient against the Distribution Service:

OGG> CONNECT http://dbhost01:9002 AS oggadmin PASSWORD ******
OGG> ADD DISTPATH dp1 SOURCE trail://localhost/services/v2/sources?trail=ea -
     TARGET wss://tgthost01:9003/services/v2/targets?trail=ra
OGG> START DISTPATH dp1
OGG> INFO DISTPATH dp1

I’ve used wss:// (secure WebSocket) for the target because that’s what you want across any network you don’t fully trust … and it’s the protocol tied to the USERIDALIAS/password method above. The Receiver Service on the target picks the path up automatically; there’s nothing to configure there beyond having it running and the port reachable. That’s a real simplification over Classic’s Manager-plus-Collector dance.

Lets discuss a real time production cutover sequence, specially what comes fiorst and second and later on as it matters the most.

– Stop Classic Extract, let pump and Replicat drain fully:

GGSCI> STOP EXTRACT ext1
GGSCI> SEND EXTRACT pump1, LOGEND
GGSCI> INFO REPLICAT rep1

Note: Wait for Replicat to show At EOF, no more records to process. Don't rush this. Its important.

– Stop pump and Replicat, record final positions, stop Manager:

GGSCI> STOP EXTRACT pump1
GGSCI> STOP REPLICAT rep1
GGSCI> INFO REPLICAT rep1, SHOWCH
GGSCI> STOP MANAGER

– Start the MA Extract at the SCN where Classic stopped:

OGG> ALTER EXTRACT ext1, SCN 45892017334
OGG> START EXTRACT ext1

– Start the path, then the Replicat:

OGG> START DISTPATH dp1
OGG> START REPLICAT rep1

– Verify with real traffic before declaring victory 🙂

OGG> INFO ALL
OGG> STATS EXTRACT ext1, TOTAL
OGG> STATS REPLICAT rep1, TOTAL
OGG> LAG REPLICAT rep1

Then do an actual data comparison … Veridata if licensed, row-count-plus-checksum on your highest-churn tables if not. I’ve seen an Extract sit happily in RUNNING while silently missing a table because a wildcard didn’t match what someone assumed.

Some of the items that might surpirse you 🙂

Ports. Four to five per deployment plus ServiceManager. If firewall changes take two weeks in your shop, start that request before the migration. Reverse proxy. Oracle recommends fronting the services with NGINX so everything comes through one port … there’s a generator script under the reverse proxy tooling. Worth doing, but I get the migration working direct-to-ports first, then add the proxy. Trail compatibility. Classic-written trails can be read by MA Replicat, so if you need to, you can point an MA Replicat at old Classic trails to drain them. Useful escape hatch. Desupported parameters (Thanks for highlighting it @Alex Lima). Audit your files. 23ai removed Integrated Replicat, dropped Blowfish encryption, removed trace-table functionality, and replaced FILTERTABLE with EXCLUDEFILTERTABLE. If any of those are lurking in a parameter file, they’ll bite you.

adminclient is not GGSCI. ADD/START/STOP/INFO/STATS behave as expected; SEND MANAGER is gone; DBLOGIN is per-service and you’ll re-issue it more often than you’d like.

The migration itself is a few hours for a typical two-node config. The real time cost is elsewhere … updating monitoring, rewriting operational scripts against the REST API instead of parsing GGSCI output, retraining whoever runs the environment at 3am. Start that early. The Extract and Replicat will be fine. It’s the ecosystem around them that takes the time.

Hope It Helped!
Prashant Dixit

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

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. NOTE: This being reverted to old mechanism, now you will see DMLs are blocked and only SELECTS are allowed. Be cautious about it and should perform this task in downtime.

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 »