<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Uptime Architect</title>
    <description>The latest articles on DEV Community by Uptime Architect (@uptimearchitect).</description>
    <link>https://dev.to/uptimearchitect</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3973084%2Fdc838b73-4aab-46b5-8272-3b9ced38ce77.png</url>
      <title>DEV Community: Uptime Architect</title>
      <link>https://dev.to/uptimearchitect</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/uptimearchitect"/>
    <language>en</language>
    <item>
      <title>Oracle Flashback: Undo at the Database Level</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sun, 30 Aug 2026 20:21:09 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-flashback-undo-at-the-database-level-1k33</link>
      <guid>https://dev.to/uptimearchitect/oracle-flashback-undo-at-the-database-level-1k33</guid>
      <description>&lt;p&gt;Someone runs &lt;code&gt;UPDATE accounts SET balance = 0&lt;/code&gt; and forgets the &lt;code&gt;WHERE&lt;/code&gt;. Commits it. A thousand balances are gone. The instinct is to reach for the backup — find last night's copy, restore it somewhere, extract the rows, lose everything that happened since. Hours, if it works at all.&lt;/p&gt;

&lt;p&gt;There's a faster answer, and it's built into the database: &lt;em&gt;undo the mistake in place.&lt;/em&gt; Oracle &lt;strong&gt;Flashback&lt;/strong&gt; is a family of features that reverse human error at point-in-time — a query, a table, a dropped object, or the entire database — in seconds to minutes, without restoring anything. And here's the part that catches teams off guard: &lt;strong&gt;your standby doesn't help.&lt;/strong&gt; A Data Guard replica faithfully replicated that &lt;code&gt;UPDATE&lt;/code&gt; to the standby within seconds. Replication is not a backup; it copies your mistakes as diligently as your data. Flashback is what actually saves you.&lt;/p&gt;

&lt;p&gt;The confusion is that "Flashback" is not one thing — it's half a dozen, built on different plumbing, with different reach. Knowing which one to grab, and where each one stops, is the whole skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The family, and what each is built on
&lt;/h2&gt;

&lt;p&gt;Every Flashback feature reverses time, but they read from different places, and that determines their limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flashback Query / Version Query&lt;/strong&gt; — read a table's &lt;em&gt;past&lt;/em&gt; directly, from &lt;strong&gt;UNDO&lt;/strong&gt;. Nothing is changed; you just &lt;code&gt;SELECT&lt;/code&gt; as of a past time. Bounded by &lt;code&gt;undo_retention&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flashback Table (to SCN/time)&lt;/strong&gt; — rewind a table's &lt;em&gt;rows&lt;/em&gt; to a past point, also from UNDO. Actually changes the data back. Bounded by &lt;code&gt;undo_retention&lt;/code&gt;, and needs &lt;strong&gt;row movement&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flashback Drop&lt;/strong&gt; — recover a &lt;em&gt;dropped table&lt;/em&gt; from the &lt;strong&gt;recycle bin&lt;/strong&gt;, where dropped objects sit until space is needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flashback Database&lt;/strong&gt; — rewind the &lt;em&gt;entire database&lt;/em&gt; to a past point, from &lt;strong&gt;flashback logs&lt;/strong&gt; (or a guaranteed restore point). The heavy hammer; needs &lt;code&gt;ARCHIVELOG&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flashback Data Archive&lt;/strong&gt; — long-term history for chosen tables, so you can query &lt;em&gt;years&lt;/em&gt; back, long after UNDO is gone. (Different problem — compliance history, not oops-recovery.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule of thumb: the smaller the blast radius, the cheaper and faster the fix. Reach for the narrowest tool that covers what you broke.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpdksFu2zAMhl-F0GUtsCb3Yuhgx8rWoagTW90O8w6UxcZeLSmQ5RlBkHef5KRDvBsB6f8-iuKR1VYRuwf22tmxbtB5EFllAJKfFSutJt-0Zgc760E6-0amYr_g7u4B0mPFfjToP_TgGwLZYe_BoWqH_nPFThGRhotQsW9DODFECryFkvPpvu0UKPRYsYm2DbZ1QDQS6zfYDuQOn6RbPpT8ia8ELBYLSErI18F-Rc4NgUfZUWjC2bEHdASjs2Y3hW8kKnjZZIngyyyABL-96MRMJyJiSkSPyKFcPQPcxJYnLmj7hzQZfzvXJ2c5jNiDcna_J3XhZzN-Fs6u8Slf5wWHrMg3QeOoPtSBIlvzHz91FuOUNO7ofgJoNAew8jfVvoclIIyNDdG-bki_jzJL5_IwZIn97H0FL0XsYJM_Pot_L02K1dfH7_wp__Lex3YCFoFXUB1m4MIfjq1v7BC-mnpvXdwNhCga9peQOIdimV2V6blmH4FpchpbFbbuyMIu6Gn_FL3i0Hl2Ov0FDpHKcw" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpdksFu2zAMhl-F0GUtsCb3Yuhgx8rWoagTW90O8w6UxcZeLSmQ5RlBkHef5KRDvBsB6f8-iuKR1VYRuwf22tmxbtB5EFllAJKfFSutJt-0Zgc760E6-0amYr_g7u4B0mPFfjToP_TgGwLZYe_BoWqH_nPFThGRhotQsW9DODFECryFkvPpvu0UKPRYsYm2DbZ1QDQS6zfYDuQOn6RbPpT8ia8ELBYLSErI18F-Rc4NgUfZUWjC2bEHdASjs2Y3hW8kKnjZZIngyyyABL-96MRMJyJiSkSPyKFcPQPcxJYnLmj7hzQZfzvXJ2c5jNiDcna_J3XhZzN-Fs6u8Slf5wWHrMg3QeOoPtSBIlvzHz91FuOUNO7ofgJoNAew8jfVvoclIIyNDdG-bki_jzJL5_IwZIn97H0FL0XsYJM_Pot_L02K1dfH7_wp__Lex3YCFoFXUB1m4MIfjq1v7BC-mnpvXdwNhCga9peQOIdimV2V6blmH4FpchpbFbbuyMIu6Gn_FL3i0Hl2Ov0FDpHKcw" alt="What did you break, and which Flashback undoes it. Start narrow: a query to look, a table-" width="1142" height="696"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What did you break, and which Flashback undoes it. Start narrow: a query to look, a table-level flashback for one object, the whole-database rewind only when the damage is broad. The heavier the tool, the more it needs set up in advance.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Look before you leap: Flashback Query
&lt;/h2&gt;

&lt;p&gt;Before you &lt;em&gt;change&lt;/em&gt; anything, you can &lt;em&gt;see&lt;/em&gt; the past. Flashback Query reads a table as it was, straight from UNDO — no restore, no downtime, just a &lt;code&gt;SELECT&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- how many rows matched five minutes ago?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="n"&gt;SYSTIMESTAMP&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'5'&lt;/span&gt; &lt;span class="k"&gt;MINUTE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- or as of a precise SCN you captured before the change&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;SCN&lt;/span&gt; &lt;span class="mi"&gt;12345678&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its sibling, &lt;strong&gt;Flashback Version Query&lt;/strong&gt;, shows you the &lt;em&gt;history&lt;/em&gt; of a row — every version between two points, with who-did-what pseudo-columns — which is how you find &lt;em&gt;when&lt;/em&gt; the damage happened before you undo it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;versions_startscn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;versions_operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="n"&gt;VERSIONS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="n"&gt;SYSTIMESTAMP&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'10'&lt;/span&gt; &lt;span class="k"&gt;MINUTE&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;SYSTIMESTAMP&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;versions_operation&lt;/code&gt; reads &lt;code&gt;I&lt;/code&gt;/&lt;code&gt;U&lt;/code&gt;/&lt;code&gt;D&lt;/code&gt; — insert, update, delete. This is often all you need: read the good values as of the right SCN and put them back yourself. But for a whole table, there's a cleaner way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Undo a bad DML: Flashback Table to SCN
&lt;/h2&gt;

&lt;p&gt;Someone zeroed every balance and committed. You don't need to reconstruct anything — rewind the table itself to the moment before the mistake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- capture the SCN BEFORE the change (or find it with a version query afterward)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;dbms_flashback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_system_change_number&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dual&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- e.g. 12345678&lt;/span&gt;

&lt;span class="c1"&gt;-- ... the bad UPDATE happens, gets committed ...&lt;/span&gt;

&lt;span class="c1"&gt;-- rewind just this table to that point&lt;/span&gt;
&lt;span class="n"&gt;FLASHBACK&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;SCN&lt;/span&gt; &lt;span class="mi"&gt;12345678&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two requirements bite people here. First, the table must have been created with &lt;strong&gt;&lt;code&gt;ENABLE ROW MOVEMENT&lt;/code&gt;&lt;/strong&gt; — Flashback Table physically re-inserts the recovered rows, changing their rowids, and Oracle refuses unless row movement is on. It's the single most common reason this fails. Second, you can't flash back across a structural change (a DDL) to the table, and Oracle won't let you flash back to within a few seconds of the table's creation (&lt;code&gt;ORA-01466&lt;/code&gt;). Within those limits it's instant, keeps working within &lt;code&gt;undo_retention&lt;/code&gt;, and leaves triggers and indexes intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recover a dropped table: Flashback Drop
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DROP TABLE&lt;/code&gt; doesn't (by default) destroy the table — it renames it and moves it to the &lt;strong&gt;recycle bin&lt;/strong&gt;, a per-user holding area where it waits until the tablespace actually needs the space. So a drop is reversible until then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- oops&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- bring it back, rows and all, from the recycle bin&lt;/span&gt;
&lt;span class="n"&gt;FLASHBACK&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- what's in the bin?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;object_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;original_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;droptime&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;recyclebin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know. &lt;code&gt;DROP TABLE accounts PURGE&lt;/code&gt; &lt;strong&gt;bypasses&lt;/strong&gt; the recycle bin — the object is gone immediately, no flashback. And the recycle bin is not infinite insurance: objects are purged automatically under space pressure, so "it was in the recycle bin last week" is not a recovery plan. It's a safety net for the &lt;em&gt;recent&lt;/em&gt; accidental drop, which is exactly when you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rewind everything: Flashback Database
&lt;/h2&gt;

&lt;p&gt;Sometimes the damage is broad — a bad deployment, a truncate spree, a dropped &lt;em&gt;schema&lt;/em&gt;. Table-level tools won't cut it. &lt;strong&gt;Flashback Database&lt;/strong&gt; rewinds the entire database to a past SCN, timestamp, or named restore point, using flashback logs it has been keeping since you turned the feature on. It's the closest thing to a database-wide undo button — and the one that must be set up &lt;em&gt;before&lt;/em&gt; the disaster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- prerequisites (once): ARCHIVELOG + a flashback recovery area&lt;/span&gt;
&lt;span class="c1"&gt;-- then, before a risky change, drop a marker you can rewind to:&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;RESTORE&lt;/span&gt; &lt;span class="n"&gt;POINT&lt;/span&gt; &lt;span class="n"&gt;before_upgrade&lt;/span&gt; &lt;span class="n"&gt;GUARANTEE&lt;/span&gt; &lt;span class="n"&gt;FLASHBACK&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- ... the disaster: someone drops the whole app schema ...&lt;/span&gt;

&lt;span class="c1"&gt;-- rewind the WHOLE database to the restore point&lt;/span&gt;
&lt;span class="n"&gt;SHUTDOWN&lt;/span&gt; &lt;span class="k"&gt;IMMEDIATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;STARTUP&lt;/span&gt; &lt;span class="n"&gt;MOUNT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;FLASHBACK&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;RESTORE&lt;/span&gt; &lt;span class="n"&gt;POINT&lt;/span&gt; &lt;span class="n"&gt;before_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="k"&gt;OPEN&lt;/span&gt; &lt;span class="n"&gt;RESETLOGS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;strong&gt;guaranteed&lt;/strong&gt; restore point is the seatbelt you fasten before anything risky — an upgrade, a big data load, a schema migration. It pins the flashback logs so you can &lt;em&gt;always&lt;/em&gt; get back to that exact point, no matter how much changes. In a multitenant database you can even rewind a single pluggable database with &lt;code&gt;FLASHBACK PLUGGABLE DATABASE&lt;/code&gt;, leaving the others untouched. The catch is the setup: Flashback Database is only available if the database was already in &lt;code&gt;ARCHIVELOG&lt;/code&gt; mode with flashback logging (or a guaranteed restore point) in place. Turn it on when things are calm; you can't retrofit it mid-crisis.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't take my word for it — run it.&lt;/strong&gt; The &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/flashback" rel="noopener noreferrer"&gt;Flashback lab&lt;/a&gt; builds a 1,000-row &lt;code&gt;ACCOUNTS&lt;/code&gt; table and stages three disasters, each reversed and &lt;strong&gt;asserted&lt;/strong&gt;: a committed &lt;code&gt;UPDATE&lt;/code&gt; with no &lt;code&gt;WHERE&lt;/code&gt; zeroes all 1,000 balances → &lt;code&gt;FLASHBACK TABLE ... TO SCN&lt;/code&gt; brings them back (0 still zero); the table is dropped → &lt;code&gt;FLASHBACK TABLE ... TO BEFORE DROP&lt;/code&gt; restores it with all 1,000 rows; and the entire &lt;code&gt;LABUSER&lt;/code&gt; schema is dropped → the lab enables &lt;code&gt;ARCHIVELOG&lt;/code&gt;, creates a guaranteed restore point, and &lt;code&gt;FLASHBACK DATABASE TO RESTORE POINT&lt;/code&gt; rewinds the whole database, bringing the schema back with its 1,000 rows. If any recovery doesn't restore the data, the run fails. All three are proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What teams get wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Believing a standby is a backup.&lt;/strong&gt; Data Guard replicates your &lt;code&gt;DELETE&lt;/code&gt; to the standby in seconds. A replica protects against &lt;em&gt;hardware&lt;/em&gt; loss, not &lt;em&gt;human&lt;/em&gt; error — for that you need Flashback (or a delayed standby apply). Don't confuse the two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creating tables without &lt;code&gt;ENABLE ROW MOVEMENT&lt;/code&gt;.&lt;/strong&gt; Then the day you need &lt;code&gt;FLASHBACK TABLE ... TO SCN&lt;/code&gt;, it's refused. Turn row movement on for tables you'd ever want to rewind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming Flashback Query reaches back forever.&lt;/strong&gt; It's bounded by &lt;code&gt;undo_retention&lt;/code&gt; and the size of the undo tablespace — under pressure, old undo is overwritten and you get &lt;code&gt;ORA-01555&lt;/code&gt;. For guaranteed reach, set &lt;code&gt;RETENTION GUARANTEE&lt;/code&gt; on undo, or use a Flashback Data Archive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DROP ... PURGE&lt;/code&gt; out of habit, or a full recycle bin.&lt;/strong&gt; &lt;code&gt;PURGE&lt;/code&gt; skips the recycle bin entirely, and the bin is auto-purged under space pressure. Neither is a bug — just know that "flashback drop" only works while the object is still in the bin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never enabling Flashback Database — or never testing it.&lt;/strong&gt; It's unavailable unless &lt;code&gt;ARCHIVELOG&lt;/code&gt; + flashback logging were on &lt;em&gt;before&lt;/em&gt; the disaster. Turn it on, size the FRA, and actually run a flashback-to-restore-point drill, so it's not the first time when it counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A guaranteed restore point quietly filling the FRA.&lt;/strong&gt; Guaranteed restore points pin flashback logs forever; leave one lying around and the recovery area fills, which can &lt;em&gt;hang the database&lt;/em&gt;. Drop the restore point once the risky change is safely done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reaching for Flashback Database when a table flashback would do.&lt;/strong&gt; Rewinding the whole database to fix one table punishes everyone. Match the tool to the blast radius — narrowest first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is Oracle Flashback?
&lt;/h3&gt;

&lt;p&gt;Oracle Flashback is a family of features that let you reverse the effects of human error at a point in time without restoring a backup. It includes Flashback Query and Version Query (read a table as it was in the past, from undo data), Flashback Table (rewind a table’s rows to a past SCN or time), Flashback Drop (recover a dropped table from the recycle bin), Flashback Database (rewind the entire database to a past point using flashback logs or a restore point), and Flashback Data Archive (retain long-term history for chosen tables). Each reads from different underlying data — undo, the recycle bin, or flashback logs — which determines how far back it can go and what it requires.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between Flashback and restoring from a backup?
&lt;/h3&gt;

&lt;p&gt;Restoring from a backup (for example with RMAN) copies datafiles from a saved backup and rolls forward, which is slower, usually needs a separate location or downtime, and typically loses everything since the backup unless you also apply archived logs. Flashback undoes changes in place using data the database already keeps — undo, the recycle bin, or flashback logs — so recovery is fast and surgical, from seconds to minutes, with no restore. Flashback is the right tool for recent human error (a bad UPDATE, a dropped table, a bad deployment); RMAN restore is the fallback for media loss, corruption, or when the change is older than your flashback and undo retention allows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does FLASHBACK TABLE ... TO SCN require row movement?
&lt;/h3&gt;

&lt;p&gt;Flashback Table recovers rows by physically deleting the current rows and re-inserting the past versions, which gives them new rowids. Oracle only permits an operation that changes rowids if the table has row movement enabled, so FLASHBACK TABLE ... TO SCN (or TO TIMESTAMP) fails on a table created without it. You enable it with CREATE TABLE ... ENABLE ROW MOVEMENT or ALTER TABLE name ENABLE ROW MOVEMENT. This is the most common reason a table-level flashback is refused. Note that Flashback Drop (TO BEFORE DROP) and Flashback Query do not need row movement — only the table-to-SCN rewind does.&lt;/p&gt;

&lt;h3&gt;
  
  
  How far back can Flashback Query go?
&lt;/h3&gt;

&lt;p&gt;Flashback Query and Flashback Table read from undo, so they can go back only as far as undo for the changed blocks still exists — governed by the UNDO_RETENTION parameter and the size of the undo tablespace. Under write pressure Oracle may overwrite unexpired undo to avoid failing DML, so a query too far back can return ORA-01555 (snapshot too old). To make the reach reliable, set RETENTION GUARANTEE on the undo tablespace (which prevents overwriting unexpired undo) and size it accordingly, or use a Flashback Data Archive for tables that need long-term, guaranteed history measured in months or years.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is FLASHBACK TABLE ... TO BEFORE DROP and the recycle bin?
&lt;/h3&gt;

&lt;p&gt;When you DROP a table, Oracle does not immediately destroy it by default — it renames the object and its dependents and moves them to the recycle bin, a per-user logical holding area, where they remain until the tablespace needs the space. FLASHBACK TABLE name TO BEFORE DROP restores the table and its data from the recycle bin, optionally renaming it with RENAME TO. Two caveats: DROP TABLE name PURGE bypasses the recycle bin and destroys the object immediately (no flashback), and objects in the recycle bin are purged automatically under space pressure, so it is a safety net for a recent accidental drop, not a long-term backup.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does Flashback Database require?
&lt;/h3&gt;

&lt;p&gt;Flashback Database rewinds the entire database to a past SCN, timestamp, or restore point using flashback logs, and it must be set up in advance. The database must be in ARCHIVELOG mode with a fast recovery area (FRA) configured, and either flashback logging turned on (ALTER DATABASE FLASHBACK ON) or a guaranteed restore point created, so the flashback logs exist to rewind through. The operation itself is run with the database mounted (not open): SHUTDOWN, STARTUP MOUNT, FLASHBACK DATABASE TO ..., then ALTER DATABASE OPEN RESETLOGS. Because these prerequisites cannot be added after the fact, enable Flashback Database before you need it and test a flashback-to-restore-point drill.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I flash back a single pluggable database (PDB)?
&lt;/h3&gt;

&lt;p&gt;Yes. In a multitenant database you can rewind one PDB independently with FLASHBACK PLUGGABLE DATABASE, leaving the other PDBs and the rest of the CDB untouched, provided the CDB is in ARCHIVELOG mode and local undo is in use (the default in recent releases). You can flash a PDB back to a PDB-level restore point, an SCN, or a timestamp, and you can create guaranteed restore points scoped to a single PDB. This is far less disruptive than flashing back the whole container database, so for damage confined to one PDB it is the right level to operate at — the same "match the tool to the blast radius" principle as choosing between table and database flashback.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a Data Guard standby protect against an accidental DELETE?
&lt;/h3&gt;

&lt;p&gt;No, not by itself. A standby applies the same redo the primary generated, so a committed accidental DELETE or a bad UPDATE is replicated to the standby within seconds, just like legitimate changes — replication cannot tell a mistake from intended work. Standbys protect against media and site failure, not logical or human error. The defenses for human error are Flashback (Query, Table, Drop, or Database) on the primary, and, at the standby level, a deliberately delayed apply (DELAY on the apply, or a snapshot standby) that gives you a window to intervene before the mistake reaches the standby. Treat replication and point-in-time undo as complementary, not interchangeable.&lt;/p&gt;

&lt;p&gt;Flashback is the recovery discipline’s scalpel, and it belongs next to the heavier tools, not instead of them: &lt;a href="https://uptimearchitect.com/blog/oracle-rman-recovery-runbook/" rel="noopener noreferrer"&gt;RMAN restore and recovery&lt;/a&gt; is the fallback for media loss and corruption, &lt;a href="https://uptimearchitect.com/blog/oracle-data-guard-switchover-vs-failover/" rel="noopener noreferrer"&gt;Data Guard&lt;/a&gt; protects against site and hardware failure, and the &lt;a href="https://uptimearchitect.com/blog/oracle-ha-decision-tree-rac-vs-data-guard/" rel="noopener noreferrer"&gt;HA decision tree&lt;/a&gt; places each where it belongs. Human error is the failure they don’t cover — and it’s the most common one. Turn Flashback Database on while things are calm, enable row movement on the tables you’d ever want to rewind, and know which tool matches which mistake. Then prove all three recoveries end to end with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/flashback" rel="noopener noreferrer"&gt;Flashback lab&lt;/a&gt;, so the first time you undo a disaster isn’t in production with everyone watching.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-flashback-undo-at-the-database-level/" rel="noopener noreferrer"&gt;uptimearchitect.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>flashback</category>
      <category>flashbackdatabase</category>
      <category>flashbacktable</category>
    </item>
    <item>
      <title>Oracle Transparent Data Encryption: Prove the Datafile Is Unreadable</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Wed, 26 Aug 2026 12:23:29 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-transparent-data-encryption-prove-the-datafile-is-unreadable-4b66</link>
      <guid>https://dev.to/uptimearchitect/oracle-transparent-data-encryption-prove-the-datafile-is-unreadable-4b66</guid>
      <description>&lt;p&gt;"The data is encrypted at rest." Everyone says it. Almost nobody checks it. It goes in the compliance questionnaire, the auditor ticks the box, and the actual bytes on disk stay a mystery until the day a backup tape, a decommissioned disk, or a cloned VM image walks out the door and someone finds out the hard way whether it was true.&lt;/p&gt;

&lt;p&gt;It's a checkable claim. Take a database, write a recognizable string into a table, and read the datafile off disk with &lt;code&gt;grep&lt;/code&gt;. If the string is sitting there in plaintext, the data is not encrypted at rest, whatever the questionnaire says. If it's gone — replaced by ciphertext — it is. &lt;strong&gt;Transparent Data Encryption&lt;/strong&gt; is how you get the second answer, and the whole point of this post is that you don't have to take it on faith. You can watch it happen.&lt;/p&gt;

&lt;p&gt;But first, the part most TDE write-ups skip, and the part that decides whether it's protecting you or just making you feel protected: &lt;em&gt;what it actually defends against.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What TDE protects — and what it doesn't
&lt;/h2&gt;

&lt;p&gt;TDE encrypts data &lt;strong&gt;at rest&lt;/strong&gt;: in the datafiles, in RMAN backups, in redo and undo and temp on disk, in Data Pump exports (with the right flag). It does this transparently — the application sees plaintext, the SQL doesn't change, and a user with the right privileges reads the data exactly as before. That transparency is the feature &lt;em&gt;and&lt;/em&gt; the boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It protects the files.&lt;/strong&gt; A stolen datafile, a lost backup, a snapshot copied out of the storage array, a disk sent back to the vendor without being wiped — all unreadable without the key. This is the threat TDE is built for, and it's a real one: the data breach that starts with "someone got a copy of the storage" is common precisely because it bypasses every database control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does &lt;em&gt;not&lt;/em&gt; protect against a logged-in user.&lt;/strong&gt; An attacker who has valid credentials, or a SQL injection hole that runs queries as the app, sees plaintext — because the database decrypts for anyone authorized to read, which is the entire design. TDE is not access control. Stopping &lt;em&gt;that&lt;/em&gt; is what privileges, &lt;a href="https://uptimearchitect.com/blog/oracle-database-hardening-checklist/" rel="noopener noreferrer"&gt;hardening&lt;/a&gt;, and &lt;a href="https://uptimearchitect.com/blog/oracle-unified-auditing/" rel="noopener noreferrer"&gt;auditing&lt;/a&gt; are for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does &lt;em&gt;not&lt;/em&gt; encrypt data in the buffer cache or on the wire.&lt;/strong&gt; Blocks in the SGA are plaintext; TDE is about disk. Encrypting the &lt;em&gt;connection&lt;/em&gt; is a separate setting (native network encryption or TLS).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get that boundary right and TDE is one of the highest-value, lowest-friction controls you can turn on. Get it wrong — treat it as a magic "now we're secure" switch — and you've encrypted the disk while leaving the front door open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The keystore is the whole game
&lt;/h2&gt;

&lt;p&gt;TDE is a two-level key hierarchy, and understanding it is understanding TDE. The actual data is encrypted with &lt;strong&gt;tablespace (or column) encryption keys&lt;/strong&gt;. Those keys live &lt;em&gt;in the datafiles themselves&lt;/em&gt; — but they're encrypted, wrapped by a single &lt;strong&gt;master encryption key&lt;/strong&gt;. The master key is the one thing that does &lt;strong&gt;not&lt;/strong&gt; live with the data. It lives in a separate &lt;strong&gt;keystore&lt;/strong&gt; (historically called the Oracle wallet).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpV0U1Lw0AQBuC_MuypBWtVPIn0YiJILEIbEDEeJruTZsk2CfthCW3_u5NtKHjaOew8vDNzFLJTJJ5AVKY7yBqthzwpWoB19l2INTpPFqiVdui97lpoaHgu7XJl9C850C34miBLv7b5xyaF2QGNIT8vxA8sFqvTwWLvYAmhjdUJ8lHNsTTkepR05ZzvLCn2nFYUTYUeK22IqTFOnkVwSjKaii7lCRI2E_4Opelk44BjKu2aCM9G61aVFYzYfNJcKHccqIbXt_d0y-2fNXpwte7jTCyhbEIPneWasxm6kFP7GOfyxlVRq_6h2_Rlk-asZtSzSj1a9ARFeLi7f4zD-Vq3Oxi6ACh94J0NsAto1dVfZxMsbkDsye5RK77SUXD3Pt5LUYXBeHE-_wFx9pcv" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpV0U1Lw0AQBuC_MuypBWtVPIn0YiJILEIbEDEeJruTZsk2CfthCW3_u5NtKHjaOew8vDNzFLJTJJ5AVKY7yBqthzwpWoB19l2INTpPFqiVdui97lpoaHgu7XJl9C850C34miBLv7b5xyaF2QGNIT8vxA8sFqvTwWLvYAmhjdUJ8lHNsTTkepR05ZzvLCn2nFYUTYUeK22IqTFOnkVwSjKaii7lCRI2E_4Opelk44BjKu2aCM9G61aVFYzYfNJcKHccqIbXt_d0y-2fNXpwte7jTCyhbEIPneWasxm6kFP7GOfyxlVRq_6h2_Rlk-asZtSzSj1a9ARFeLi7f4zD-Vq3Oxi6ACh94J0NsAto1dVfZxMsbkDsye5RK77SUXD3Pt5LUYXBeHE-_wFx9pcv" alt="The two-level hierarchy, and why the keystore is separate. The data keys travel with the d" width="346" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The two-level hierarchy, and why the keystore is separate. The data keys travel with the datafiles; the master key that unlocks them does not. Steal the .dbf and you have ciphertext plus a locked box. You need the keystore too.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That separation is the entire security model, and it has a sharp consequence: &lt;strong&gt;if the keystore is stolen along with the datafiles, TDE has protected nothing.&lt;/strong&gt; Which is exactly the mistake an &lt;em&gt;auto-login&lt;/em&gt; keystore invites. A software keystore comes in three flavors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Password keystore&lt;/strong&gt; — the database can't open it without a human (or a script) supplying the password. Safest, most operationally annoying: someone has to open the wallet after every restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-login keystore&lt;/strong&gt; — the database opens it automatically at startup. Convenient, and how most production databases run. But the auto-login file, if it sits next to the datafiles and gets copied with them, hands the thief the key. Keep it off the data volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local auto-login keystore&lt;/strong&gt; — auto-login, but &lt;em&gt;tied to the host it was created on&lt;/em&gt;. Copy it to another machine and it won't open. This is usually the right default: the database opens its own wallet, but a stolen copy is useless elsewhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For anything beyond a single box, the master key belongs in a real key manager — &lt;strong&gt;Oracle Key Vault&lt;/strong&gt; or an HSM — not a file on the same server at all. The file keystore is where you start; centralized key management is where a fleet ends up.&lt;/p&gt;

&lt;p&gt;Modern Oracle (19c and up) configures all of this through two settings — &lt;code&gt;WALLET_ROOT&lt;/code&gt; (a static parameter, so setting it needs one restart) and &lt;code&gt;TDE_CONFIGURATION&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- one-time: tell the database where keystores live, then turn on the FILE keystore&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;WALLET_ROOT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'/etc/oracle/wallets/prod'&lt;/span&gt; &lt;span class="k"&gt;SCOPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SPFILE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- then restart&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TDE_CONFIGURATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'KEYSTORE_CONFIGURATION=FILE'&lt;/span&gt; &lt;span class="k"&gt;SCOPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;BOTH&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- create the keystore, open it, and set the master key&lt;/span&gt;
&lt;span class="n"&gt;ADMINISTER&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;MANAGEMENT&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;KEYSTORE&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;strong-pw&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ADMINISTER&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;MANAGEMENT&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;KEYSTORE&lt;/span&gt; &lt;span class="k"&gt;OPEN&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;strong-pw&amp;gt;"&lt;/span&gt; &lt;span class="n"&gt;CONTAINER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ADMINISTER&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;MANAGEMENT&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;strong-pw&amp;gt;"&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;WITH BACKUP&lt;/code&gt; is not optional decoration. &lt;strong&gt;Lose the master key and the data is gone&lt;/strong&gt; — not locked, &lt;em&gt;gone&lt;/em&gt;, as unrecoverable as if you'd deleted it. Back the keystore up, separately from the database backups, and guard it like the crown jewel it is. More teams have lost data to a lost wallet than to a stolen one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tablespace encryption, not column encryption (usually)
&lt;/h2&gt;

&lt;p&gt;TDE has two modes, and the choice is easier than it looks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tablespace encryption&lt;/strong&gt; encrypts an entire tablespace — every table, index, and LOB in it — with AES. It's fully transparent, imposes a small and roughly fixed CPU cost, and has no functional surprises: every query, every index, every join works exactly as before. This is the default answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Column encryption&lt;/strong&gt; encrypts specific columns. It sounds surgical and appealing — "just encrypt the card number" — but it comes with real limitations: an encrypted column can't be a foreign key, can't be indexed with a normal range scan, and breaks some optimizations. Reach for it only when you genuinely need one or two columns encrypted and can't encrypt the tablespace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Creating an encrypted tablespace is one clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLESPACE&lt;/span&gt; &lt;span class="n"&gt;app_secure&lt;/span&gt;
  &lt;span class="n"&gt;DATAFILE&lt;/span&gt; &lt;span class="s1"&gt;'/opt/oracle/oradata/FREE/FREEPDB1/app_secure.dbf'&lt;/span&gt; &lt;span class="k"&gt;SIZE&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt;
  &lt;span class="n"&gt;ENCRYPTION&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="s1"&gt;'AES256'&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;STORAGE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ENCRYPT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And you don't have to rebuild the world to encrypt what you already have. Since 12.2, you can convert an existing tablespace &lt;strong&gt;online&lt;/strong&gt;, with the application still running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;TABLESPACE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;ENCRYPTION&lt;/span&gt; &lt;span class="n"&gt;ONLINE&lt;/span&gt; &lt;span class="n"&gt;ENCRYPT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- no outage; encrypts in the background&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New data written to an encrypted tablespace is encrypted; the online convert handles the existing blocks. There's no "half-encrypted" state a query can trip over — the transparency holds throughout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now prove it
&lt;/h2&gt;

&lt;p&gt;Here's the part that separates "we enabled TDE" from "we checked." Put the same recognizable rows into an encrypted tablespace and an ordinary one, flush them to disk, and read the raw datafiles — not through the database, which would helpfully decrypt for you, but straight off the filesystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# a distinctive canary string is written into BOTH tablespaces' tables, then flushed to disk.&lt;/span&gt;
&lt;span class="c"&gt;# read the bytes on disk directly (grep -a treats the binary datafile as text):&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'CANARY_TDE'&lt;/span&gt; /opt/oracle/oradata/FREE/FREEPDB1/tde_plain.dbf   &lt;span class="c"&gt;# -&amp;gt; 133   (plaintext, right there)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'CANARY_TDE'&lt;/span&gt; /opt/oracle/oradata/FREE/FREEPDB1/tde_enc.dbf     &lt;span class="c"&gt;# -&amp;gt;   0   (ciphertext, gone)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same rows, same fake card numbers, same everything — the only difference is one tablespace was created &lt;code&gt;ENCRYPTION USING 'AES256'&lt;/code&gt;. In the ordinary datafile the canary is sitting there in the clear, 133 times over. In the encrypted one it's simply not there; the bytes are AES ciphertext. That's encryption at rest, demonstrated rather than asserted — and it's exactly what a thief with a copy of your storage would find.&lt;/p&gt;

&lt;p&gt;The keystore is what stands between those two outcomes. Close it and the database itself goes blind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;ADMINISTER&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;MANAGEMENT&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;KEYSTORE&lt;/span&gt; &lt;span class="k"&gt;CLOSE&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;strong-pw&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secrets_enc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ORA-28365: wallet is not open&lt;/span&gt;

&lt;span class="n"&gt;ADMINISTER&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;MANAGEMENT&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;KEYSTORE&lt;/span&gt; &lt;span class="k"&gt;OPEN&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;strong-pw&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- readable again, no data lost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the wallet closed, the encrypted table throws &lt;strong&gt;ORA-28365&lt;/strong&gt; on any read — the master key is out of memory, so the tablespace key can't be unwrapped, so the blocks can't be decrypted. The ordinary table keeps reading fine. That's the whole model in one gesture: the key, not the file, is the thing you're protecting.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't take my word for it — run it.&lt;/strong&gt; The &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/tde" rel="noopener noreferrer"&gt;TDE lab&lt;/a&gt; stands up an Oracle Database Free container, configures a software keystore, and builds one &lt;strong&gt;encrypted&lt;/strong&gt; tablespace and one &lt;strong&gt;ordinary&lt;/strong&gt; one holding identical canary rows. It then reads both datafiles off disk and &lt;strong&gt;asserts&lt;/strong&gt; the canary appears in the plaintext file (133 hits) and is &lt;strong&gt;absent&lt;/strong&gt; from the encrypted one (0 hits) — encryption at rest, proven on the actual bytes. Then it closes the keystore and asserts the encrypted read fails with &lt;strong&gt;ORA-28365&lt;/strong&gt; while the plaintext read still works, and that reopening the wallet restores access. If the canary shows up in the encrypted datafile, or closing the wallet doesn't block the read, the run fails. The whole thing is proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What teams get wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auto-login wallet next to the datafiles.&lt;/strong&gt; The single most common way to make TDE pointless: the auto-login keystore gets backed up or copied alongside the data it's supposed to protect. Use a &lt;em&gt;local&lt;/em&gt; auto-login keystore (host-tied), and keep it off the data volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not backing up the keystore — or losing it.&lt;/strong&gt; Lose the master key and the data is unrecoverable. The keystore needs its own backup, stored separately from the database backups (putting both in the same place recreates the theft problem). &lt;code&gt;WITH BACKUP&lt;/code&gt; on every key operation, and a real archive of the wallet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thinking TDE stops a logged-in attacker.&lt;/strong&gt; It doesn't. Encryption at rest is orthogonal to access control. A stolen password or a SQL-injection hole reads plaintext all day. TDE is one layer; auditing and least-privilege are the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting the copies.&lt;/strong&gt; Encrypting the tablespace but not the backups, or exporting with Data Pump without &lt;code&gt;ENCRYPTION&lt;/code&gt;, leaves plaintext copies lying around. The data is only as encrypted as its &lt;em&gt;least&lt;/em&gt; protected copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Column encryption where a tablespace would do.&lt;/strong&gt; Reaching for column encryption and then fighting its limits — no range-scan index, no foreign keys — when tablespace encryption would have been transparent and simpler. Encrypt the tablespace unless you have a specific reason not to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never rotating the master key.&lt;/strong&gt; The master key can and should be rekeyed periodically (and immediately if you suspect exposure) with &lt;code&gt;ADMINISTER KEY MANAGEMENT SET KEY&lt;/code&gt;. A key that never changes is a key with an ever-growing blast radius.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is Transparent Data Encryption (TDE) in Oracle?
&lt;/h3&gt;

&lt;p&gt;Transparent Data Encryption is an Oracle feature that encrypts data at rest — in datafiles, backups, redo, undo, and temp on disk — without requiring any change to the application. It is called transparent because authorized users and the SQL they run see plaintext exactly as before; the encryption and decryption happen automatically at the storage layer. TDE uses a two-level key hierarchy: tablespace or column encryption keys encrypt the data and are stored (encrypted) in the datafiles, while a single master encryption key that unwraps them is kept separately in a keystore. TDE protects against theft of the physical files or backups, not against a user who is already authenticated to the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does TDE protect against, and what does it not?
&lt;/h3&gt;

&lt;p&gt;TDE protects against threats where someone obtains the files rather than a database session: a stolen or lost backup, a decommissioned disk, a copied storage snapshot, or a cloned VM image. In all of those the data is unreadable without the master key. TDE does not protect against an attacker who has valid credentials or exploits SQL injection, because the database decrypts transparently for anyone authorized to read — that is what access control, hardening, and auditing are for. It also does not encrypt data in the buffer cache (which is plaintext in memory) or on the network connection, which needs native network encryption or TLS configured separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the Oracle keystore (wallet) and why is it kept separate?
&lt;/h3&gt;

&lt;p&gt;The keystore, historically called the Oracle wallet, is where the TDE master encryption key is stored. The tablespace and column keys that actually encrypt data live inside the datafiles, but they are themselves encrypted by the master key, and the master key lives only in the keystore. This separation is the entire security model: a stolen datafile contains ciphertext and a locked, wrapped key, but not the master key needed to open it. If the keystore is stolen together with the datafiles, TDE protects nothing, which is why an auto-login keystore should never sit on the same volume as the data and why fleets move the master key into Oracle Key Vault or an HSM.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between an auto-login and a local auto-login keystore?
&lt;/h3&gt;

&lt;p&gt;A password keystore must be opened by supplying its password, so the database cannot read encrypted data after a restart until a human or script opens the wallet. An auto-login keystore lets the database open the wallet automatically at startup, which is convenient but means anyone who copies that auto-login file can open it anywhere. A local auto-login keystore is also opened automatically, but it is tied to the host on which it was created — copied to a different machine it will not open. For most production databases a local auto-login keystore, kept off the data volume, is the right balance of convenience and safety.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use tablespace encryption or column encryption?
&lt;/h3&gt;

&lt;p&gt;Tablespace encryption is the default choice for almost all cases. It encrypts an entire tablespace — every table, index, and LOB — transparently, with a small and predictable CPU cost and no functional limitations: all queries, indexes, and joins behave exactly as before. Column encryption encrypts specific columns and sounds more surgical, but it carries real restrictions: an encrypted column cannot be a foreign key or be used in a normal range-scan index, and it interferes with some optimizations. Use column encryption only when you need to encrypt one or two specific columns and cannot encrypt the whole tablespace; otherwise encrypt the tablespace.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I encrypt an existing tablespace without downtime?
&lt;/h3&gt;

&lt;p&gt;Yes. Since Oracle 12.2 you can convert an existing tablespace to encrypted online, while the application keeps running, with ALTER TABLESPACE  ENCRYPTION ONLINE ENCRYPT. Oracle encrypts the existing blocks in the background and encrypts new data as it is written, and there is no half-encrypted state that queries can trip over. This means you do not have to create a new encrypted tablespace and migrate objects into it; you can encrypt the data where it already lives. You do need enough auxiliary space for the online conversion, and the master key and keystore must be set up first.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if I lose the TDE keystore or master key?
&lt;/h3&gt;

&lt;p&gt;If you lose the keystore and have no backup of it, the encrypted data is permanently unrecoverable — not merely locked, but effectively destroyed, because the tablespace keys inside the datafiles can never be unwrapped again. This is the most important operational risk of TDE and it causes more data loss than theft does. You must back up the keystore whenever the master key changes (the WITH BACKUP clause creates a backup at each key operation), store that keystore backup separately from the database backups so a single compromise cannot capture both, and test that you can actually open a restored keystore. Treat the keystore as the most critical artifact in the environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Oracle TDE require an extra license?
&lt;/h3&gt;

&lt;p&gt;On-premises Enterprise Edition, TDE is part of the Advanced Security Option, which is a separately licensed pack, so using it on-prem EE requires that license. In Oracle Cloud Infrastructure, including Autonomous Database and the Base Database and Exadata cloud services, TDE is included and is typically enabled by default, so cloud databases are encrypted at rest out of the box. Because licensing terms change and depend on your edition and platform, confirm your specific entitlement before enabling TDE in production on-premises rather than assuming it is included.&lt;/p&gt;

&lt;p&gt;Encryption at rest is the fourth leg of the same security-and-ops discipline as the rest: &lt;a href="https://uptimearchitect.com/blog/oracle-patching-cpu-ru-rur/" rel="noopener noreferrer"&gt;patching&lt;/a&gt; closes known vulnerabilities, the &lt;a href="https://uptimearchitect.com/blog/oracle-database-hardening-checklist/" rel="noopener noreferrer"&gt;hardening checklist&lt;/a&gt; closes the configuration gaps, &lt;a href="https://uptimearchitect.com/blog/oracle-unified-auditing/" rel="noopener noreferrer"&gt;unified auditing&lt;/a&gt; tells you when either is being tested, and TDE makes sure that when the files themselves are the target, what walks out the door is unreadable. Do it deliberately: the right keystore type, kept off the data volume and backed up separately, tablespace encryption over column encryption, and the master key rotated on a schedule. Then prove it the way that ends the argument — with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/tde" rel="noopener noreferrer"&gt;TDE lab&lt;/a&gt;, where you can watch the canary vanish from the datafile and watch the database go blind the moment the wallet closes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-transparent-data-encryption-tde/" rel="noopener noreferrer"&gt;uptimearchitect.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>tde</category>
      <category>transparentdataencryption</category>
      <category>encryptionatrest</category>
    </item>
    <item>
      <title>Oracle SQL Plan Management: Stop a Good Plan From Going Bad</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Wed, 19 Aug 2026 19:25:20 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-sql-plan-management-stop-a-good-plan-from-going-bad-338d</link>
      <guid>https://dev.to/uptimearchitect/oracle-sql-plan-management-stop-a-good-plan-from-going-bad-338d</guid>
      <description>&lt;p&gt;A query that ran in fifty milliseconds for a year is suddenly taking thirty seconds. Nobody changed the code. Nobody changed the query. And that's exactly the problem: &lt;em&gt;the optimizer is allowed to change its mind.&lt;/em&gt; New statistics, a bind variable it peeked at differently, an upgrade, a dropped index — any of them can hand the same statement a new plan, and the new plan can be worse. Most of the time you never notice. The time you notice, it's a pager at 2 a.m.&lt;/p&gt;

&lt;p&gt;The two posts before this one were about making the optimizer's &lt;em&gt;estimate&lt;/em&gt; right — &lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;reading the plan to find where the estimate went wrong&lt;/a&gt;, and &lt;a href="https://uptimearchitect.com/blog/oracle-optimizer-statistics-demystified/" rel="noopener noreferrer"&gt;fixing the statistics behind it&lt;/a&gt;. This one is about the plan you've &lt;em&gt;already got right&lt;/em&gt; and want to keep. Because "get the estimate right" is a moving target: statistics get re-gathered, data shifts, and a plan that was correct on Tuesday is not guaranteed on Wednesday. &lt;strong&gt;SQL Plan Management&lt;/strong&gt; is the seatbelt. It lets the optimizer keep improving plans everywhere else while refusing to let a known-good plan silently regress on the statements you can't afford to have go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a SQL plan baseline actually is
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;SQL plan baseline&lt;/strong&gt; is a stored set of &lt;em&gt;accepted&lt;/em&gt; execution plans for one SQL statement. It lives in the SQL Management Base in &lt;code&gt;SYSAUX&lt;/code&gt;, and it's keyed by the statement's &lt;strong&gt;signature&lt;/strong&gt; — a hash of the normalized SQL text (case- and whitespace-insensitive, but literal-sensitive, which is one more reason to use bind variables). When a statement with a baseline parses, the optimizer does something subtly different from its usual job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It builds its best-cost plan the way it always does, from the current statistics.&lt;/li&gt;
&lt;li&gt;Then it checks the baseline. If that best-cost plan is already &lt;strong&gt;accepted&lt;/strong&gt;, it just uses it — nothing to protect against.&lt;/li&gt;
&lt;li&gt;If the best-cost plan is &lt;em&gt;not&lt;/em&gt; accepted, the optimizer sets it aside as a &lt;strong&gt;non-accepted&lt;/strong&gt; plan (kept for later, in case it's genuinely better) and instead runs the best &lt;strong&gt;accepted&lt;/strong&gt; plan that still reproduces against today's schema.&lt;/li&gt;
&lt;li&gt;Only if &lt;em&gt;no&lt;/em&gt; accepted plan can be reproduced — say the index it needs was dropped — does it fall back to the best-cost plan.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The effect is a ratchet. The optimizer is free to find new plans, but it can't &lt;em&gt;use&lt;/em&gt; one you haven't blessed. A plan you captured while things were good stays in force even when the cost model, fed newer statistics, starts preferring something else.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp1kktPAjEQx7_KpBc1kfiIJ2MwIiga4yPowbgehu0gjd2WdGY1SPjuzi4L4uvc-f0fM52ZPFoyh2BGPr7nY0wC990sAJw8ZWYgKFRQEJhgYuLMPEOr1YaOvt1MxBXugxIMS-ctgxM-Gqad9pBYWnlkhTwGGKVYgESL0w0GVsFKpTLo1FKns8z0Ag49WRjcXdXMQgaZvAsEk0SsEY4zM6-wU8XgOtZwV3M8MIGMCb7bNh718CNxPd1TqwvWYZQvG_SJ0E4B85wmQnZp01snzxofJ5CV-7t7BxCijF140WKaLwrlAviCLrA0zr21mOeK32J6rXhkQKVDa2n4FWVzFBN43XgCeou-FBfDViN3Xgv1tUE3aijd62-exXkPiTSQLXNaNumvN7n4sbE_ZK61zuHqFrC6Q8lkmzT9tXKX_95gUSrE7x6rgE5vXtUz22AKSgU6q_9wZlSoqH-kpRGWXsx8_gkVc9z9" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp1kktPAjEQx7_KpBc1kfiIJ2MwIiga4yPowbgehu0gjd2WdGY1SPjuzi4L4uvc-f0fM52ZPFoyh2BGPr7nY0wC990sAJw8ZWYgKFRQEJhgYuLMPEOr1YaOvt1MxBXugxIMS-ctgxM-Gqad9pBYWnlkhTwGGKVYgESL0w0GVsFKpTLo1FKns8z0Ag49WRjcXdXMQgaZvAsEk0SsEY4zM6-wU8XgOtZwV3M8MIGMCb7bNh718CNxPd1TqwvWYZQvG_SJ0E4B85wmQnZp01snzxofJ5CV-7t7BxCijF140WKaLwrlAviCLrA0zr21mOeK32J6rXhkQKVDa2n4FWVzFBN43XgCeou-FBfDViN3Xgv1tUE3aijd62-exXkPiTSQLXNaNumvN7n4sbE_ZK61zuHqFrC6Q8lkmzT9tXKX_95gUSrE7x6rgE5vXtUz22AKSgU6q_9wZlSoqH-kpRGWXsx8_gkVc9z9" alt="What the optimizer does when an enabled baseline exists. It still computes its best-cost p" width="830" height="1406"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What the optimizer does when an enabled baseline exists. It still computes its best-cost plan — it just isn't allowed to run it unless you've accepted it. A newly found plan is parked as non-accepted for later verification, not used on the spot.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The three flags that decide everything
&lt;/h2&gt;

&lt;p&gt;Every plan in a baseline carries three independent states, and mixing them up is the single most common source of "SPM isn't working":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ENABLED&lt;/code&gt;&lt;/strong&gt; — the plan is eligible for consideration at all. Disable it and the optimizer ignores it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ACCEPTED&lt;/code&gt;&lt;/strong&gt; — the plan is allowed to &lt;em&gt;run&lt;/em&gt;. This is the one that matters. A captured plan can be enabled but not accepted (it's a candidate, waiting to prove itself); the optimizer will not use it until it's accepted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FIXED&lt;/code&gt;&lt;/strong&gt; — the plan is &lt;em&gt;preferred&lt;/em&gt;. When a baseline has any fixed plans, the optimizer chooses only among those and stops adding new candidates. A fixed baseline is how you say "this, and nothing else, until I say otherwise."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mental model: &lt;code&gt;ENABLED&lt;/code&gt; is "in the pool," &lt;code&gt;ACCEPTED&lt;/code&gt; is "cleared to fly," &lt;code&gt;FIXED&lt;/code&gt; is "and it's the captain." Most of SPM is moving plans between those states deliberately instead of letting it happen to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capturing the plan you want to keep
&lt;/h2&gt;

&lt;p&gt;There are three ways plans get into a baseline. The one you'll reach for most is loading a plan you already have in the cursor cache — you found the good plan, now you pin it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- pin the plan currently in the cursor cache for one SQL_ID&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
  &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="n"&gt;NUMBER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DBMS_SPM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOAD_PLANS_FROM_CURSOR_CACHE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql_id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'a1b2c3d4e5f6g'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_OUTPUT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PUT_LINE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' plan(s) loaded'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plans loaded this way arrive &lt;strong&gt;enabled and accepted&lt;/strong&gt; — ready to enforce immediately. The other two sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automatic capture.&lt;/strong&gt; Set &lt;code&gt;OPTIMIZER_CAPTURE_SQL_PLAN_BASELINES = TRUE&lt;/code&gt; and Oracle records a baseline for every repeatable statement it sees. The &lt;em&gt;first&lt;/em&gt; plan for a statement is auto-accepted (it becomes the reference); any &lt;em&gt;different&lt;/em&gt; plan found later is captured as non-accepted, waiting for you to verify it. Powerful, but it's a blanket — turn it on around a risky change (an upgrade, a big data load), capture what you need, then turn it back off rather than leaving it on forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From a SQL tuning set / AWR.&lt;/strong&gt; &lt;code&gt;DBMS_SPM.LOAD_PLANS_FROM_SQLSET&lt;/code&gt; pulls plans out of a tuning set, which is how you seed baselines from AWR history — including from the &lt;em&gt;old&lt;/em&gt; release before an upgrade, so the plans that worked last quarter are on file before the new optimizer ever runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the killer use case: &lt;strong&gt;capture baselines from the current release, upgrade, and the optimizer inherits a floor.&lt;/strong&gt; New plans still get found and parked as candidates, but nothing regresses on day one because every statement already has its old, proven plan accepted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm it's actually in force
&lt;/h2&gt;

&lt;p&gt;Two checks. First, does the baseline exist and what state is it in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;sql_handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;dba_sql_plan_baselines&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;sql_text&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%shop.orders%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the check that matters — that the plan the optimizer &lt;em&gt;actually built&lt;/em&gt; came from the baseline. The plan's Note section says so outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_XPLAN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DISPLAY_CURSOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FORMAT&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ALLSTATS LAST'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;-- ...&lt;/span&gt;
&lt;span class="c1"&gt;-- Note&lt;/span&gt;
&lt;span class="c1"&gt;-- -----&lt;/span&gt;
&lt;span class="c1"&gt;--    - SQL plan baseline SYS_SQL_PLAN_1a2b3c4d used for this statement&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that line is there, the baseline held. If it isn't — and you expected it to — the usual reasons are &lt;code&gt;OPTIMIZER_USE_SQL_PLAN_BASELINES&lt;/code&gt; turned off, the plan isn't &lt;em&gt;accepted&lt;/em&gt; (only enabled), or the accepted plan can no longer be reproduced because the schema changed underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evolving: how a baseline gets &lt;em&gt;better&lt;/em&gt; without getting worse
&lt;/h2&gt;

&lt;p&gt;A baseline that never changes eventually holds a plan that's genuinely stale — a new index really would be faster now. That's what &lt;strong&gt;evolution&lt;/strong&gt; is for. &lt;code&gt;DBMS_SPM.EVOLVE_SQL_PLAN_BASELINE&lt;/code&gt; takes the non-accepted candidates piling up in the baseline, &lt;em&gt;runs them&lt;/em&gt;, and accepts one only if it actually performs better than the current accepted plan by a real margin. This is the crucial difference from just letting the optimizer loose: a plan is promoted on &lt;strong&gt;measured&lt;/strong&gt; performance, not on estimated cost.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- verify the parked candidates and accept only the ones that prove faster&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;DBMS_SPM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EVOLVE_SQL_PLAN_BASELINE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql_handle&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SQL_1a2b3c4d5e6f7g8h'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dual&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From 19c on, the &lt;strong&gt;automatic SPM evolve advisor task&lt;/strong&gt; does exactly this in the maintenance window: it evaluates the candidates that accumulated during the day and auto-accepts the ones that verify faster. So the healthy steady state is: baselines protect you from regressions, and evolution quietly promotes real improvements after they've proven themselves. You get stability &lt;em&gt;and&lt;/em&gt; progress, instead of choosing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't take my word for it — run it.&lt;/strong&gt; The &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/sql-plan-management" rel="noopener noreferrer"&gt;SQL Plan Management lab&lt;/a&gt; builds a million-row &lt;code&gt;ORDERS&lt;/code&gt; table with a rare, indexed &lt;code&gt;status&lt;/code&gt; and a histogram, so &lt;code&gt;WHERE status = 'OPEN'&lt;/code&gt; gets the cheap &lt;strong&gt;index&lt;/strong&gt; plan. It captures that plan as an accepted baseline, then &lt;strong&gt;drops the histogram&lt;/strong&gt; to simulate the everyday stats drift that breaks plans — now the optimizer estimates a third of the table and costs a &lt;strong&gt;full scan&lt;/strong&gt; as cheaper. The lab runs the same query twice and &lt;strong&gt;asserts both outcomes&lt;/strong&gt;: with baselines off, the plan regresses to a full table scan; with baselines on, the accepted index plan is held (it's still reproducible because the index exists) and &lt;code&gt;DBMS_XPLAN&lt;/code&gt; reports &lt;em&gt;SQL plan baseline ... used for this statement&lt;/em&gt;. If the regression doesn't happen, or the baseline doesn't stop it, the run fails. The whole before/after is proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What teams get wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Confusing enabled with accepted.&lt;/strong&gt; A captured candidate plan is enabled but &lt;em&gt;not&lt;/em&gt; accepted, and the optimizer won't run it. People load a plan, see it in &lt;code&gt;DBA_SQL_PLAN_BASELINES&lt;/code&gt;, and assume it's in force — then wonder why nothing changed. Check the &lt;code&gt;ACCEPTED&lt;/code&gt; column, not just that a row exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing a baseline and forgetting.&lt;/strong&gt; A &lt;code&gt;FIXED&lt;/code&gt; baseline stops accepting new candidates entirely. That's correct for a statement you never want touched — and a slow poison for one whose data grew tenfold since, because the plan can no longer improve even when it should. Fixed plans need a review date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving automatic capture on forever.&lt;/strong&gt; Blanket capture around an upgrade is smart; leaving it on fills the SQL Management Base with baselines for one-off and ad-hoc statements and quietly grows &lt;code&gt;SYSAUX&lt;/code&gt;. Turn it on with intent, capture, turn it off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baselining statements full of literals.&lt;/strong&gt; Signatures are literal-sensitive. A statement that inlines &lt;code&gt;WHERE id = 48213&lt;/code&gt; has a &lt;em&gt;different&lt;/em&gt; baseline from &lt;code&gt;WHERE id = 48214&lt;/code&gt;. Without bind variables you're baselining a million near-identical statements, none of which recurs. Bind first, baseline second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating a baseline as a fix for bad statistics.&lt;/strong&gt; SPM freezes a plan; it doesn't make the estimate correct. If you pin a plan to paper over stale stats, you've hidden the problem, not solved it — and every &lt;em&gt;other&lt;/em&gt; statement on that table still gets the bad estimate. Fix the number, then baseline the good plan it produces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capturing on the new release, after the regression.&lt;/strong&gt; The highest-value moment to capture is &lt;em&gt;before&lt;/em&gt; an upgrade, from the release that still works. Seed baselines from AWR on the old version and the new optimizer inherits a floor instead of a surprise.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is SQL Plan Management in Oracle?
&lt;/h3&gt;

&lt;p&gt;SQL Plan Management (SPM) is an Oracle feature that prevents execution plans from regressing by controlling which plans the optimizer is allowed to use for a statement. It stores accepted plans as SQL plan baselines in the SQL Management Base. When a statement that has a baseline is parsed, the optimizer still computes its best-cost plan, but it will only execute a plan that has been accepted into the baseline; a newly found plan is recorded as a non-accepted candidate for later verification rather than used immediately. This lets the optimizer keep finding better plans while guaranteeing that a known-good plan cannot be silently replaced by a worse one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between enabled, accepted, and fixed plans in a baseline?
&lt;/h3&gt;

&lt;p&gt;Enabled means the plan is eligible for the optimizer to consider at all. Accepted means the plan is allowed to actually run — this is the state that determines whether a plan is used, and a plan can be enabled but not accepted, in which case it is only a candidate. Fixed means the plan is preferred: when a baseline contains any fixed plans, the optimizer chooses only among the fixed plans and stops adding new candidates to the baseline. A plan loaded from the cursor cache arrives enabled and accepted; a plan captured automatically as an alternative arrives enabled but not accepted until it is evolved or manually accepted.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I capture a SQL plan baseline?
&lt;/h3&gt;

&lt;p&gt;There are three main ways. First, load a plan already in the cursor cache with DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE for a specific SQL_ID — this loads the plan enabled and accepted, ready to enforce. Second, enable automatic capture by setting OPTIMIZER_CAPTURE_SQL_PLAN_BASELINES to TRUE, which records a baseline for every repeatable statement (the first plan is auto-accepted and later alternatives are captured as non-accepted). Third, load plans from a SQL tuning set with DBMS_SPM.LOAD_PLANS_FROM_SQLSET, which is how you seed baselines from AWR history, including from an older release before an upgrade.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know if a SQL plan baseline is being used?
&lt;/h3&gt;

&lt;p&gt;Run the statement and pull its plan with DBMS_XPLAN.DISPLAY_CURSOR using a format such as ALLSTATS LAST or TYPICAL. If a baseline built the plan, the Note section at the bottom reads "SQL plan baseline  used for this statement." You can also query V$SQL.SQL_PLAN_BASELINE for the cursor, which holds the baseline plan name when one was applied. If you expected a baseline and neither appears, check that OPTIMIZER_USE_SQL_PLAN_BASELINES is TRUE, that the plan is accepted and not merely enabled, and that the accepted plan is still reproducible against the current schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does it mean to evolve a SQL plan baseline?
&lt;/h3&gt;

&lt;p&gt;Evolving a baseline means testing the non-accepted candidate plans that have accumulated for a statement and accepting one only if it actually performs better than the current accepted plan. DBMS_SPM.EVOLVE_SQL_PLAN_BASELINE runs the candidates and promotes them based on measured performance, not estimated cost, which is what makes evolution safe. From Oracle 19c on, the automatic SPM evolve advisor task performs this verification in the maintenance window and auto-accepts candidates that prove faster, so baselines protect against regressions while still allowing genuinely better plans to be adopted after they have been verified.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a SQL plan baseline fix bad statistics?
&lt;/h3&gt;

&lt;p&gt;No. A baseline freezes which plan a statement uses; it does not correct the optimizer cardinality estimate. If you pin a plan to work around stale or missing statistics, you have hidden that one symptom while every other statement against the same table still gets the wrong estimate. The correct order is to fix the statistics first — a histogram for a skewed column, extended statistics for correlated columns, a fresh gather where the data moved — and then capture a baseline of the good plan that results, so it is protected going forward. SPM is a stability tool, not a statistics tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use SQL plan baselines before an Oracle upgrade?
&lt;/h3&gt;

&lt;p&gt;Yes — an upgrade is one of the strongest use cases. A new optimizer version can change plans for statements that were previously fine, and some of those changes are regressions. If you capture baselines from the current release before upgrading — for example by loading plans from AWR history into a SQL tuning set and then into baselines — the upgraded optimizer inherits an accepted plan for each of those statements and cannot silently regress them on day one. New and potentially better plans are still found and parked as candidates, so you can evolve them deliberately after the upgrade rather than discovering regressions in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between a SQL plan baseline and a SQL profile?
&lt;/h3&gt;

&lt;p&gt;A SQL plan baseline stores one or more complete, accepted execution plans and constrains the optimizer to use an accepted plan, which makes it a plan-stability mechanism. A SQL profile does not store a plan; it stores corrective information — essentially adjustment factors for the optimizer estimates — that helps the optimizer build a better plan, but it does not lock a specific plan in place. In practice you use a SQL profile to help the optimizer estimate correctly and a baseline to guarantee a specific proven plan is used. They can coexist: a profile improves the estimate while a baseline fixes the resulting plan.&lt;/p&gt;

&lt;p&gt;SQL Plan Management is the last stop in the same performance discipline as the rest: an &lt;a href="https://uptimearchitect.com/blog/how-to-read-an-awr-report/" rel="noopener noreferrer"&gt;AWR report&lt;/a&gt; points you at the expensive SQL, &lt;a href="https://uptimearchitect.com/blog/oracle-wait-events-decoded/" rel="noopener noreferrer"&gt;wait events&lt;/a&gt; tell you what a session is stuck on, &lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;the execution plan&lt;/a&gt; shows &lt;em&gt;where&lt;/em&gt; the estimate went wrong, and &lt;a href="https://uptimearchitect.com/blog/oracle-optimizer-statistics-demystified/" rel="noopener noreferrer"&gt;statistics&lt;/a&gt; are usually &lt;em&gt;why&lt;/em&gt;. Once you've done that work and the plan is right, a baseline is how you keep it right — the optimizer stays free to improve everything else, and the plan you fought for doesn't quietly unravel the next time the numbers move. Prove it end to end with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/sql-plan-management" rel="noopener noreferrer"&gt;SQL Plan Management lab&lt;/a&gt;: watch a plan regress to a full scan when it's unprotected, and watch the baseline hold the line when it isn't.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-sql-plan-management-baselines/" rel="noopener noreferrer"&gt;uptimearchitect.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>sqlplanmanagement</category>
      <category>sqlplanbaselines</category>
      <category>dbmsspm</category>
    </item>
    <item>
      <title>Oracle Optimizer Statistics, Demystified</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Mon, 17 Aug 2026 01:59:32 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-optimizer-statistics-demystified-37f9</link>
      <guid>https://dev.to/uptimearchitect/oracle-optimizer-statistics-demystified-37f9</guid>
      <description>&lt;p&gt;The optimizer doesn't guess. It &lt;em&gt;calculates&lt;/em&gt; — and every number in that calculation comes from statistics you either gathered on purpose or left to rot.&lt;/p&gt;

&lt;p&gt;That's the part the "the optimizer is dumb" stories always skip. Reading an execution plan, you find the line where the estimate parted ways with reality — the step that expected five rows and got five million, and dragged the whole plan down with it. That's not the optimizer being stupid. That's the optimizer doing correct arithmetic on a wrong number. It thought five rows because &lt;em&gt;the statistics said five rows&lt;/em&gt;. Fix the plan and you're treating a symptom. Fix the statistics and the bad plan never gets built.&lt;/p&gt;

&lt;p&gt;So here's the reframe: tuning statistics isn't a nightly ritual of gathering &lt;em&gt;everything&lt;/em&gt; and hoping. Most of that is cargo cult — full re-gathers that burn a maintenance window to re-confirm numbers that hadn't moved. The actual skill is narrow. Gather the &lt;em&gt;right&lt;/em&gt; things — a histogram where a column is skewed, an &lt;strong&gt;extended statistic where columns are correlated&lt;/strong&gt; — keep them fresh where the data actually moves, and let the automatic job handle the rest instead of fighting it. Everything below is which numbers matter and how to keep them honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the optimizer is actually reading
&lt;/h2&gt;

&lt;p&gt;When the optimizer estimates how many rows a step returns — its &lt;em&gt;cardinality&lt;/em&gt; — it's doing one sum: &lt;code&gt;rows × selectivity&lt;/code&gt;. The row count and the selectivity both come from stored statistics, gathered by &lt;code&gt;DBMS_STATS&lt;/code&gt; and kept in the data dictionary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Table statistics&lt;/strong&gt; — number of rows, number of blocks, average row length. This is the &lt;code&gt;rows&lt;/code&gt; in the sum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Column statistics&lt;/strong&gt; — for each column: number of distinct values (NDV), low and high value, number of nulls, and density. This is where basic &lt;code&gt;selectivity&lt;/code&gt; comes from: for &lt;code&gt;col = :x&lt;/code&gt;, the optimizer assumes &lt;code&gt;1/NDV&lt;/code&gt; of the rows match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index statistics&lt;/strong&gt; — height, leaf blocks, clustering factor (how well index order matches table order). This decides whether an index access is actually cheaper than a scan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Histograms&lt;/strong&gt; — the shape of a column's data when it &lt;em&gt;isn't&lt;/em&gt; evenly distributed. Without one, the optimizer assumes every value is equally common. (More below.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get those right and the optimizer makes good choices. Feed it a stale row count, a missing histogram, or two columns it thinks are independent, and it makes a bad one — confidently, because the math checks out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Histograms: when "1/NDV" lies
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;1/NDV&lt;/code&gt; assumption falls apart the moment a column is &lt;strong&gt;skewed&lt;/strong&gt; — a few values far more common than the rest. If &lt;code&gt;status&lt;/code&gt; is 99% &lt;code&gt;CLOSED&lt;/code&gt; and 0.1% &lt;code&gt;OPEN&lt;/code&gt;, but the optimizer only knows there are two distinct values, it estimates half the table for either one. A &lt;strong&gt;histogram&lt;/strong&gt; records the real distribution so the optimizer stops assuming and starts knowing. You need one when a column is skewed &lt;em&gt;and&lt;/em&gt; shows up in &lt;code&gt;WHERE&lt;/code&gt; clauses — and Oracle's default &lt;code&gt;METHOD_OPT&lt;/code&gt; of &lt;code&gt;FOR ALL COLUMNS SIZE AUTO&lt;/code&gt; creates them only for exactly those columns, using its record of which columns get queried.&lt;/p&gt;

&lt;p&gt;This is the single most common estimate bug, and it's the one the &lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;execution-plans lab&lt;/a&gt; reproduces end to end — a missing histogram turning a 500-row query into a full-table scan. So I won't relitigate it here. The &lt;em&gt;second&lt;/em&gt; most common bug gets far less airtime and is just as destructive:&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlated columns: the estimate that quietly collapses
&lt;/h2&gt;

&lt;p&gt;Give the optimizer two predicates on the same table and, by default, it assumes the columns are &lt;strong&gt;independent&lt;/strong&gt; — it multiplies their selectivities together. &lt;code&gt;WHERE make = 'TOYOTA' AND model = 'CAMRY'&lt;/code&gt; becomes &lt;code&gt;selectivity(make) × selectivity(model)&lt;/code&gt;. That's fine when the columns really are unrelated. It's a disaster when they aren't — because every Camry &lt;em&gt;is&lt;/em&gt; a Toyota. The &lt;code&gt;model&lt;/code&gt; predicate already implies the &lt;code&gt;make&lt;/code&gt;; multiplying by &lt;code&gt;selectivity(make)&lt;/code&gt; a second time divides the estimate by the number of makes for no reason at all.&lt;/p&gt;

&lt;p&gt;The result is a severe &lt;strong&gt;under&lt;/strong&gt;-estimate — the mirror image of the skew problem. The optimizer thinks a few dozen rows match when tens of thousands do, so it reaches for an index and a nested loop that would be perfect for a few dozen rows and catastrophic for tens of thousands. You can't fix this with a histogram; neither column is individually skewed. You fix it by telling the optimizer the two columns travel together — an &lt;strong&gt;extended statistic&lt;/strong&gt; on the column group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- create the column group AND gather it in one step&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_STATS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GATHER_TABLE_STATS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ownname&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tabname&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'CARS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;method_opt&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'FOR ALL COLUMNS SIZE AUTO FOR COLUMNS (make, model)'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;

&lt;span class="c1"&gt;-- confirm the extension exists&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;extension_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extension&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;user_stat_extensions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'CARS'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the optimizer stores a real NDV for the &lt;em&gt;combination&lt;/em&gt; of &lt;code&gt;make&lt;/code&gt; and &lt;code&gt;model&lt;/code&gt;, and the estimate snaps to the truth. Extended statistics also cover &lt;strong&gt;expressions&lt;/strong&gt; — if you query &lt;code&gt;WHERE UPPER(last_name) = 'SMITH'&lt;/code&gt;, a statistic on &lt;code&gt;(UPPER(last_name))&lt;/code&gt; gives the optimizer a real selectivity for the expression instead of a blind guess. (There's an automation for this too: the preference &lt;code&gt;AUTO_STAT_EXTENSIONS&lt;/code&gt;, off by default, lets Oracle create column groups on its own from the predicates it sees. Useful, but I like knowing exactly which extensions exist rather than discovering them.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The estimate that rots: stale, and out of range
&lt;/h2&gt;

&lt;p&gt;The other way good statistics go bad is simply &lt;strong&gt;time&lt;/strong&gt;. Stats are a snapshot; the data keeps moving. Two symptoms matter most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stale stats on a changing table.&lt;/strong&gt; Oracle flags a table's stats stale once roughly &lt;strong&gt;10%&lt;/strong&gt; of its rows have changed since the last gather. Past that line, the optimizer is reasoning about a table that no longer exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The out-of-range predicate.&lt;/strong&gt; This one's sneakier. Gather stats today, and the optimizer's &lt;code&gt;high_value&lt;/code&gt; for &lt;code&gt;order_date&lt;/code&gt; is today. Query tomorrow for &lt;code&gt;order_date &amp;gt; SYSDATE - 1&lt;/code&gt; and you're asking about values &lt;em&gt;past the edge of what the stats know&lt;/em&gt; — so the optimizer estimates almost nothing matched and under-reads. On any ever-growing table (orders, events, logs), the newest and most-queried data is exactly the data the last gather never saw.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are why you don't just gather once and forget. But they're also why you shouldn't gather &lt;em&gt;everything nightly&lt;/em&gt; — most tables don't cross the staleness line most nights. Which is what the automatic job is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the automatic job do the boring part
&lt;/h2&gt;

&lt;p&gt;Modern Oracle gathers statistics for you, and it's good at it. The &lt;strong&gt;automatic optimizer statistics task&lt;/strong&gt; runs in the maintenance window and gathers stats for any object whose stats have gone stale — not every object, just the ones that moved. Two newer pieces make it sharper, both from 19c on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;High-frequency automatic statistics&lt;/strong&gt; — a lightweight task that checks for stale objects every &lt;strong&gt;15 minutes&lt;/strong&gt; rather than waiting for the nightly window, so fast-moving tables don't spend all day on yesterday's numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time statistics&lt;/strong&gt; — the optimizer maintains basic stats &lt;em&gt;as conventional DML runs&lt;/em&gt;, so a table loaded this morning isn't invisible until tonight's gather. (Availability varies by edition and platform — know whether yours has it.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your job is mostly to &lt;em&gt;not get in the way&lt;/em&gt;. The &lt;code&gt;DBMS_STATS&lt;/code&gt; defaults are the product of two decades of the optimizer team's scar tissue — &lt;code&gt;AUTO_SAMPLE_SIZE&lt;/code&gt; (a fast, accurate scan rather than a guessed sample percent) and &lt;code&gt;SIZE AUTO&lt;/code&gt; (histograms only where they're earned). Overriding them with a hand-picked &lt;code&gt;estimate_percent&lt;/code&gt; or a blanket &lt;code&gt;SIZE 254&lt;/code&gt; is how people make stats &lt;em&gt;worse&lt;/em&gt; while feeling productive. Leave the defaults, and reach for manual gathering only in the specific cases the automation can't see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- a good manual gather looks like the defaults, on purpose&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_STATS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GATHER_TABLE_STATS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ownname&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tabname&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ORDERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;estimate_percent&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_STATS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AUTO_SAMPLE_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;method_opt&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'FOR ALL COLUMNS SIZE AUTO'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;cascade&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two overrides that &lt;em&gt;are&lt;/em&gt; worth knowing. &lt;strong&gt;Lock the stats on a volatile or staging table&lt;/strong&gt; so the automatic job doesn't gather it mid-load and catch it half-empty — you set representative stats once and freeze them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXEC&lt;/span&gt; &lt;span class="n"&gt;DBMS_STATS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_TABLE_STATS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'STAGING_LOAD'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when you must re-gather a critical table but can't risk a plan regression, use &lt;strong&gt;pending statistics&lt;/strong&gt; — gather them, test them in your own session, and publish only if they behave:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXEC&lt;/span&gt; &lt;span class="n"&gt;DBMS_STATS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SET_TABLE_PREFS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ORDERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PUBLISH'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'FALSE'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;EXEC&lt;/span&gt; &lt;span class="n"&gt;DBMS_STATS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GATHER_TABLE_STATS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ORDERS'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- lands as PENDING, unused by other sessions&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;optimizer_use_pending_statistics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- test the new stats, just for you&lt;/span&gt;
&lt;span class="c1"&gt;-- happy? publish. unhappy? delete them, no one else ever saw them.&lt;/span&gt;
&lt;span class="k"&gt;EXEC&lt;/span&gt; &lt;span class="n"&gt;DBMS_STATS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PUBLISH_PENDING_STATS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SALES'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ORDERS'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Is it actually your statistics?
&lt;/h2&gt;

&lt;p&gt;Before you gather anything, confirm the stats are the problem. Two checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- how old, how big, and does Oracle think they're stale?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_analyzed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stale_stats&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;user_tab_statistics&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ORDERS'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the same check that reading any plan comes down to: run the statement with &lt;code&gt;/*+ GATHER_PLAN_STATISTICS */&lt;/code&gt;, pull the plan with &lt;code&gt;DBMS_XPLAN.DISPLAY_CURSOR(FORMAT =&amp;gt; 'ALLSTATS LAST')&lt;/code&gt;, and compare &lt;strong&gt;E-Rows to A-Rows&lt;/strong&gt;. If they track, your stats are fine and the work is genuinely large — a re-gather won't help. If they diverge by an order of magnitude, you've found the estimate to fix, and this post is the menu of &lt;em&gt;why&lt;/em&gt; it's wrong. Reading that plan is the subject of its own post (&lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;Oracle Execution Plans, Decoded&lt;/a&gt;); statistics are where the fix usually lands.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpVkkFv2zAMhf8KoVOH1s069BQMHdwkbTME6xB7G7Z5B8WmbcGyWEjy3CDIfx8tOx1yIyS-7z2KOoicChRzEKWmPq-l9ZAuMwMQ_87EVy0NKAeO7zLxB6LoDu75PN5skjROE9jESTr_uLOzu5zaF2kRVtGWegeeIA4VywbafdAuDplYEvga91Cov2grDOLdHtiIbIEWqIRWVkb5rsBPmTgO6gWr4QsFxpL9Ey-9g8GuVAYh6z68v7kNJEZDT7YZUluU-hq2WHQ5gvLXU5QA-4ku0Fac6Ee9H9oHKTqvWumZYclUJ_tVaH1g42d2cw32WEBOumtNMOXLWjlPlZUtXCTrXyuIv6XP7ybDUf7I8gVZi5r5J7mDGeDri0XnFL3B8NWjKbjJDXOeUZ7G6TXyawF1PqIystJM78gNFqNK8igWLgosZae9O8-xZsJ34hCKIbPBolKmAi93-g2iKW_gEhx63oXppNb7ifEQGJ-ZscUorzFv5qeVG-rHFViZN-58_Y-jbCif_pfrsRRXIFq0rVQFf8SD4PRt-JLTAOJ4_AexrNUO" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpVkkFv2zAMhf8KoVOH1s069BQMHdwkbTME6xB7G7Z5B8WmbcGyWEjy3CDIfx8tOx1yIyS-7z2KOoicChRzEKWmPq-l9ZAuMwMQ_87EVy0NKAeO7zLxB6LoDu75PN5skjROE9jESTr_uLOzu5zaF2kRVtGWegeeIA4VywbafdAuDplYEvga91Cov2grDOLdHtiIbIEWqIRWVkb5rsBPmTgO6gWr4QsFxpL9Ey-9g8GuVAYh6z68v7kNJEZDT7YZUluU-hq2WHQ5gvLXU5QA-4ku0Fac6Ee9H9oHKTqvWumZYclUJ_tVaH1g42d2cw32WEBOumtNMOXLWjlPlZUtXCTrXyuIv6XP7ybDUf7I8gVZi5r5J7mDGeDri0XnFL3B8NWjKbjJDXOeUZ7G6TXyawF1PqIystJM78gNFqNK8igWLgosZae9O8-xZsJ34hCKIbPBolKmAi93-g2iKW_gEhx63oXppNb7ifEQGJ-ZscUorzFv5qeVG-rHFViZN-58_Y-jbCif_pfrsRRXIFq0rVQFf8SD4PRt-JLTAOJ4_AexrNUO" alt="Same loop as reading a plan, one level deeper: find the estimate that's wrong, then match " width="1105" height="1141"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same loop as reading a plan, one level deeper: find the estimate that's wrong, then match the fix to the reason it's wrong. A re-gather is one branch, not the whole tree.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't take my word for it — run it.&lt;/strong&gt; The &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/optimizer-stats" rel="noopener noreferrer"&gt;optimizer-stats lab&lt;/a&gt; builds a million-row table where &lt;code&gt;model&lt;/code&gt; determines &lt;code&gt;make&lt;/code&gt; — perfectly correlated — and joins it to a second table. It runs &lt;code&gt;WHERE make = ... AND model = ...&lt;/code&gt;, captures the real &lt;code&gt;ALLSTATS LAST&lt;/code&gt; plan, and &lt;strong&gt;asserts the under-estimate&lt;/strong&gt; — the optimizer multiplies the selectivities, expects ~200 rows, and picks a &lt;strong&gt;nested loop&lt;/strong&gt; join that's right for a couple hundred rows and a disaster for the ~10,000 that actually match. Then it creates an extended statistic on the &lt;code&gt;(make, model)&lt;/code&gt; column group, re-gathers, re-runs, and &lt;strong&gt;asserts the plan flipped&lt;/strong&gt; to a &lt;strong&gt;hash join&lt;/strong&gt; with E-Rows ≈ A-Rows. (It turns adaptive plans off first, so you see what the estimate &lt;em&gt;alone&lt;/em&gt; decides — the runtime safety net is a different lesson.) If the under-estimate doesn't reproduce, or the column group doesn't correct it, the run fails. The whole before/after is proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What teams get wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gathering everything, every night.&lt;/strong&gt; A full re-gather of tables that didn't change is a maintenance window spent re-confirming yesterday. Let the automatic task gather what's &lt;em&gt;stale&lt;/em&gt;; intervene only where it can't see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overriding the defaults to feel productive.&lt;/strong&gt; A hand-picked &lt;code&gt;estimate_percent&lt;/code&gt; or a blanket &lt;code&gt;SIZE 254&lt;/code&gt; usually makes stats slower to gather &lt;em&gt;and&lt;/em&gt; worse. &lt;code&gt;AUTO_SAMPLE_SIZE&lt;/code&gt; and &lt;code&gt;SIZE AUTO&lt;/code&gt; are the right answer far more often than not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never creating extended statistics.&lt;/strong&gt; The correlated-columns under-estimate is invisible until you know to look for it — and no amount of re-gathering the individual columns fixes it. A column group does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting histograms because "they cause plan instability."&lt;/strong&gt; The instability is usually a bind-peeking or sampling issue, not the histogram itself. Removing a histogram a skewed column needs just trades a visible problem for a quieter one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gathering stats, then not testing.&lt;/strong&gt; On a critical table, a re-gather &lt;em&gt;is&lt;/em&gt; a plan change. Use pending statistics to try it in one session before it becomes everyone's plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving volatile and staging tables to the automatic job.&lt;/strong&gt; Caught mid-load, they get stats that describe a table that only exists for ten minutes a day. Lock representative stats instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What are optimizer statistics in Oracle?
&lt;/h3&gt;

&lt;p&gt;Optimizer statistics are stored descriptions of your data that the cost-based optimizer uses to estimate how many rows each step of a query will process and how expensive each access path is. They include table statistics (row count, block count, average row length), column statistics (number of distinct values, high and low values, nulls, density), index statistics (height, leaf blocks, clustering factor), and histograms (the distribution of values in a skewed column). The optimizer combines these into a cardinality estimate; if the statistics are wrong or stale, the estimate is wrong and the plan can be poor even though the optimizer reasoned correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should I gather optimizer statistics in Oracle?
&lt;/h3&gt;

&lt;p&gt;For most tables, let the automatic optimizer statistics task do it. It runs in the maintenance window and gathers statistics only for objects whose statistics have gone stale (roughly 10% of rows changed), rather than re-gathering everything. From 19c, high-frequency automatic statistics can check for stale objects every 15 minutes, and real-time statistics maintain basic stats during DML. Gather manually only in specific cases the automation cannot handle well: right after a bulk load, on a volatile or staging table (where you usually lock stats instead), or when you need to create extended statistics. Blanket nightly re-gathers of unchanged tables waste resources without improving plans.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are extended statistics and when do I need them?
&lt;/h3&gt;

&lt;p&gt;Extended statistics are statistics on a group of columns or on an expression, rather than a single column. You need column group statistics when two or more columns are correlated and are used together in WHERE clauses — for example make and model, or country and city. By default the optimizer assumes columns are independent and multiplies their selectivities, which badly underestimates cardinality when the columns are related. A column group gives the optimizer a real number of distinct values for the combination. Expression statistics do the same for a function such as UPPER(last_name). Create them with DBMS_STATS, for example method_opt of FOR COLUMNS (make, model).&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Oracle underestimate rows when I filter on two related columns?
&lt;/h3&gt;

&lt;p&gt;Because by default the optimizer treats predicates on different columns as independent and multiplies their selectivities. If you filter on make = TOYOTA and model = CAMRY, it multiplies the selectivity of make by the selectivity of model — but every Camry is already a Toyota, so the make predicate adds no additional filtering. Multiplying by it anyway divides the estimate by the number of makes for no reason, producing a large underestimate. The optimizer then picks a plan suited to far fewer rows than actually match, such as an index range scan and nested loop where a full scan and hash join were correct. Extended statistics on the column group fix the estimate.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between a histogram and extended statistics?
&lt;/h3&gt;

&lt;p&gt;A histogram describes the distribution of values within a single column, so the optimizer knows that some values are far more common than others rather than assuming an even spread. You need one when a single column is skewed and appears in predicates. Extended statistics describe a relationship the optimizer cannot see from single-column stats: either a correlation between multiple columns (a column group) or the selectivity of an expression. They solve different problems — a histogram fixes a per-value skew underestimate or overestimate on one column, while a column group fixes the underestimate caused by treating correlated columns as independent. Some queries need both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I change the DBMS_STATS default parameters?
&lt;/h3&gt;

&lt;p&gt;Usually not. The defaults — AUTO_SAMPLE_SIZE for the sample size and FOR ALL COLUMNS SIZE AUTO for method_opt — are the result of extensive tuning by the optimizer development team. AUTO_SAMPLE_SIZE gives near-full-scan accuracy at a fraction of the cost, and SIZE AUTO creates histograms only for columns that are skewed and actually used in predicates. Overriding them with a fixed estimate_percent or a blanket histogram size such as SIZE 254 typically makes gathering slower and the resulting statistics less accurate. Change preferences deliberately and per-table when you have a specific reason, not as a global habit.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are pending statistics and how do I use them?
&lt;/h3&gt;

&lt;p&gt;Pending statistics let you gather new statistics without immediately exposing them to the optimizer, so you can test them before they affect everyone. Set the table preference PUBLISH to FALSE, gather statistics (they are stored as pending, and other sessions keep using the old published stats), then in your own session set optimizer_use_pending_statistics to TRUE and run your critical queries to check the plans. If the new statistics produce good plans, publish them with DBMS_STATS.PUBLISH_PENDING_STATS; if not, delete them and no other session was ever affected. This is the safe way to re-gather statistics on an important table where a plan regression would be costly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why should I lock statistics on some tables?
&lt;/h3&gt;

&lt;p&gt;You lock statistics on tables whose contents change so dramatically and so often that any automatically gathered snapshot is misleading — typically staging, load, or global temporary tables that are empty for most of the day and full for a few minutes. If the automatic statistics job happens to run while such a table is empty or half-loaded, the optimizer gets statistics describing a state the table is almost never in, and plans that reference it go wrong. Instead you gather representative statistics once, when the table holds a typical working set, and lock them with DBMS_STATS.LOCK_TABLE_STATS so the automatic job leaves them alone.&lt;/p&gt;

&lt;p&gt;Statistics are the layer underneath every plan the optimizer builds, which is why they sit at the root of the same performance discipline as the rest: an &lt;a href="https://uptimearchitect.com/blog/how-to-read-an-awr-report/" rel="noopener noreferrer"&gt;AWR report&lt;/a&gt; points you at the expensive SQL, &lt;a href="https://uptimearchitect.com/blog/oracle-wait-events-decoded/" rel="noopener noreferrer"&gt;wait events&lt;/a&gt; tell you what a session is stuck on, &lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;the execution plan&lt;/a&gt; shows you &lt;em&gt;where&lt;/em&gt; the estimate went wrong, and statistics are usually &lt;em&gt;why&lt;/em&gt;. Don't gather more — gather what's earned: a histogram for skew, a column group for correlation, fresh numbers where the data actually moves, and the automatic job for everything else. Prove the correlated-columns fix end to end with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/optimizer-stats" rel="noopener noreferrer"&gt;optimizer-stats lab&lt;/a&gt;, and the next time a plan goes wrong, you'll know whether to read the plan or fix the number behind it. And once the number is right and the plan is good, &lt;a href="https://uptimearchitect.com/blog/oracle-sql-plan-management-baselines/" rel="noopener noreferrer"&gt;SQL Plan Management&lt;/a&gt; is how you keep it that way — pinning the plan so the next re-gather can't quietly undo the fix.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-optimizer-statistics-demystified/" rel="noopener noreferrer"&gt;uptimearchitect.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>optimizerstatistics</category>
      <category>dbmsstats</category>
      <category>extendedstatistics</category>
    </item>
    <item>
      <title>Oracle Execution Plans, Decoded: The One Number That Matters</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Wed, 12 Aug 2026 16:16:38 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-execution-plans-decoded-the-one-number-that-matters-483k</link>
      <guid>https://dev.to/uptimearchitect/oracle-execution-plans-decoded-the-one-number-that-matters-483k</guid>
      <description>&lt;p&gt;&lt;code&gt;EXPLAIN PLAN&lt;/code&gt; is a liar. Not on purpose — it just tells you what the optimizer &lt;em&gt;hopes&lt;/em&gt; will happen, never what did. It's a forecast, printed with the confidence of a receipt.&lt;/p&gt;

&lt;p&gt;And a plan is nothing but forecasts stacked on forecasts. The optimizer guesses how many rows each step will produce, and every choice after that — which table to lead with, whether to use an index, nested loop or hash join — rests on the guess before it. Get the first estimate wrong and the whole plan tips over: it thought a step would return five rows, it returned five million, and it picked a strategy that's a catastrophe at that scale. That's what a slow query almost always is. Not a mysterious optimizer mood — one estimate that missed, and a plan that trusted it.&lt;/p&gt;

&lt;p&gt;So here's the part that changes how you tune: reading the &lt;em&gt;estimates&lt;/em&gt; is nearly useless, because the estimate is exactly the thing that's wrong. The skill is reading the estimate &lt;strong&gt;against reality&lt;/strong&gt; — putting the number the optimizer guessed next to the number it actually got, and finding the line where they diverge. Do that and the slow query stops being a mystery. Everything below is how.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop reading the guess. Get the real plan.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN PLAN&lt;/code&gt;, &lt;code&gt;AUTOTRACE&lt;/code&gt;, the plan tab in your IDE — they all show you the &lt;em&gt;estimate&lt;/em&gt; without ever running the statement. Worse, the plan they show you isn't guaranteed to be the plan that runs: bind variables get peeked, the real cursor may have been built for a different value, and &lt;code&gt;EXPLAIN PLAN&lt;/code&gt; doesn't peek at all. You can spend an afternoon tuning a plan the database never actually uses.&lt;/p&gt;

&lt;p&gt;Run the statement and ask the database what it &lt;em&gt;actually did&lt;/em&gt;. Two pieces: the &lt;code&gt;GATHER_PLAN_STATISTICS&lt;/code&gt; hint (or &lt;code&gt;STATISTICS_LEVEL = ALL&lt;/code&gt; for the session), which tells Oracle to count real rows as it goes, and &lt;code&gt;DBMS_XPLAN.DISPLAY_CURSOR&lt;/code&gt; with the &lt;code&gt;ALLSTATS LAST&lt;/code&gt; format, which prints those counts next to the estimates for the statement you just ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="cm"&gt;/*+ GATHER_PLAN_STATISTICS */&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'OPEN'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DBMS_XPLAN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DISPLAY_CURSOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FORMAT&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ALLSTATS LAST'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you a plan with columns the estimate-only tools can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;E-Rows&lt;/strong&gt; — rows the optimizer &lt;em&gt;estimated&lt;/em&gt; a step would return.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A-Rows&lt;/strong&gt; — rows it &lt;em&gt;actually&lt;/em&gt; returned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starts&lt;/strong&gt; — how many times the step ran (crucial for nested loops).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buffers&lt;/strong&gt; — logical I/O, the truest measure of work done (more on this below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the difference between a weather forecast and looking out the window. Everything that follows is reading that window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one number: E-Rows vs A-Rows
&lt;/h2&gt;

&lt;p&gt;If you learn to read exactly one thing in a plan, read this. Go down the plan and compare &lt;strong&gt;E-Rows to A-Rows on every line.&lt;/strong&gt; Where they track each other, the optimizer understood the data. Where they diverge by an order of magnitude or more, you've found the lie — and it's almost always the root of the slowness, because every operation above that line was planned for the wrong number of rows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;------------------------------------------------------------------------------------
| Id | Operation          | Name   | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
------------------------------------------------------------------------------------
|  0 | SELECT STATEMENT   |        |      1 |        |      1 |00:00:00.02 |    8616 |
|  1 |  SORT AGGREGATE    |        |      1 |      1 |      1 |00:00:00.02 |    8616 |
|* 2 |   TABLE ACCESS FULL| ORDERS |      1 |    500K|    500 |00:00:00.02 |    8616 |
------------------------------------------------------------------------------------
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line 2 is the whole story. The optimizer estimated &lt;strong&gt;500K&lt;/strong&gt; rows for &lt;code&gt;status = 'OPEN'&lt;/code&gt; and got &lt;strong&gt;500&lt;/strong&gt; — a thousandfold overshoot. Believing half the table matched, it did the sensible thing &lt;em&gt;for that belief&lt;/em&gt; and full-scanned. But only 500 rows matched, so it read over eight thousand buffers to find a few hundred rows an index would have fetched in ten. The full scan isn't the bug. The &lt;strong&gt;500K estimate&lt;/strong&gt; is the bug; the full scan is just what a rational optimizer does when you feed it a bad number.&lt;/p&gt;

&lt;p&gt;For nested loops, apply the same idea with &lt;code&gt;Starts&lt;/code&gt;: the inner step's real output is &lt;code&gt;Starts × A-Rows&lt;/code&gt;. A step showing &lt;code&gt;Starts = 50000&lt;/code&gt; is an inner probe that ran fifty thousand times because the optimizer thought the outer row source would return a handful. Same disease — a low estimate on the driver — different symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read it inside-out
&lt;/h2&gt;

&lt;p&gt;Plans read like nested parentheses, not top to bottom. The &lt;strong&gt;most-indented&lt;/strong&gt; line runs first; a parent consumes what its children produce. To find where a plan goes wrong, start at the leaves (the table and index accesses), walk outward, and watch two columns: the A-Rows that balloon and the Buffers that accumulate. The line where the row count first explodes past its estimate is your driving row source — the step that set the plan's fate. You don't need to understand all forty lines of a hairy plan. You need the one where reality parted ways with the forecast.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a full scan is right — and when it's a symptom
&lt;/h2&gt;

&lt;p&gt;A full table scan is not a failure. Reading 40% of a table, it's &lt;em&gt;faster&lt;/em&gt; than an index — an index would mean 400,000 scattered single-block reads where a scan does clean multi-block ones. The optimizer switches between index access and full scan on &lt;strong&gt;selectivity&lt;/strong&gt;: how much of the table your predicate keeps. Below roughly a few percent, the index wins; above it, the scan does.&lt;/p&gt;

&lt;p&gt;Which means a full scan is only a symptom when the optimizer reached for it &lt;em&gt;because it misjudged selectivity&lt;/em&gt; — the exact case above, where a missing histogram made a 0.05%-selective predicate look 50%-selective. So don't reflexively "add an index" or paste in an &lt;code&gt;INDEX&lt;/code&gt; hint. A hint that forces the index papers over the wrong estimate and leaves a landmine for the next value. Fix the number, and the optimizer picks the index on its own — for every value, not just the one you tested.&lt;/p&gt;

&lt;p&gt;While you're on that line, read its &lt;strong&gt;predicate section&lt;/strong&gt; (&lt;code&gt;DISPLAY_CURSOR&lt;/code&gt; prints it under the plan). It splits into &lt;code&gt;access&lt;/code&gt; predicates — what an index used to &lt;em&gt;find&lt;/em&gt; rows — and &lt;code&gt;filter&lt;/code&gt; predicates — what got applied &lt;em&gt;after&lt;/em&gt;, throwing rows away. A fat &lt;code&gt;filter&lt;/code&gt; where you expected an &lt;code&gt;access&lt;/code&gt; predicate means the index isn't doing the work you think it is: rows are being read and then discarded, not skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Buffers, not seconds
&lt;/h2&gt;

&lt;p&gt;The instinct is to chase &lt;code&gt;A-Time&lt;/code&gt;. Resist it. Elapsed time lies — it drops when data's cached, spikes when the box is busy, and changes every run. &lt;strong&gt;Buffers&lt;/strong&gt; — logical I/O, the count of buffer accesses — is the work the query actually asked for, and it's stable across warm cache, cold cache, and a loaded server. When you tune, watch Buffers fall. A rewrite that "feels faster" but moves the same Buffers just cached the blocks; a rewrite that cuts Buffers 100× is genuinely less work, and it'll still be less work at 2am under load. Time is the thing users feel; Buffers is the thing you can trust while you're fixing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the estimate was wrong
&lt;/h2&gt;

&lt;p&gt;Once E-Rows and A-Rows point you at the guilty line, there's one question left: &lt;em&gt;why did the optimizer believe that?&lt;/em&gt; Almost always one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stale or missing statistics.&lt;/strong&gt; The optimizer is reasoning about a table that no longer exists — last week's row counts, yesterday's high value. Re-gather with &lt;code&gt;DBMS_STATS&lt;/code&gt; and the estimate often just fixes itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Column skew with no histogram.&lt;/strong&gt; The classic. The optimizer assumes values are spread evenly across a column, so for &lt;code&gt;status = 'OPEN'&lt;/code&gt; it divides row count by the number of distinct values. If &lt;code&gt;OPEN&lt;/code&gt; is 0.05% of the table but there are two distinct values, it estimates 50%. A &lt;strong&gt;histogram&lt;/strong&gt; on the column tells it the truth about the distribution. (This is the worked example below.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlated columns.&lt;/strong&gt; Two predicates the optimizer treats as independent when they aren't — &lt;code&gt;WHERE make = 'Toyota' AND model = 'Camry'&lt;/code&gt;. It multiplies the two selectivities and lands far too low, because every Camry is a Toyota. &lt;strong&gt;Extended statistics&lt;/strong&gt; on the column group teach it the correlation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A function on the column.&lt;/strong&gt; &lt;code&gt;WHERE UPPER(name) = 'ACME'&lt;/code&gt; or an implicit type conversion (a number column compared to a string) makes the column &lt;em&gt;unsargable&lt;/em&gt; — the index can't be used and the estimate falls apart. Rewrite to leave the column bare, or add a function-based index.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind peeking meets skew.&lt;/strong&gt; With bind variables, the optimizer peeks at the &lt;em&gt;first&lt;/em&gt; value and builds a plan for it — great until the next value has wildly different selectivity and inherits a plan built for someone else. &lt;strong&gt;Adaptive cursor sharing&lt;/strong&gt; exists to catch this; skew plus binds is where it earns its keep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A predicate past the edge of the stats.&lt;/strong&gt; Query for a date newer than the newest value the stats know about and the optimizer estimates almost nothing matched — so it under-reads. Common on ever-growing tables between stats gathers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Modern Oracle fights back on your behalf: &lt;strong&gt;adaptive plans&lt;/strong&gt; (12c and up) let the optimizer switch join methods mid-flight when the real row counts contradict the estimate, and &lt;strong&gt;Real-Time SQL Plan Management&lt;/strong&gt; in 23ai spots a plan regression &lt;em&gt;as it happens&lt;/em&gt; and reinstates a known-good plan automatically. Both are real, and both are worth having on. But they're a safety net under the trapeze, not a reason to skip the routine: they catch some falls, they don't make your statistics tell the truth. The durable fix is still to correct the number the optimizer started from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch it happen
&lt;/h2&gt;

&lt;p&gt;Take the plan from earlier and give the optimizer what it was missing. Before, with no histogram on &lt;code&gt;status&lt;/code&gt;, it split the table evenly between two values and estimated 500K rows for &lt;code&gt;OPEN&lt;/code&gt; — so it full-scanned. Gather a histogram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_STATS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GATHER_TABLE_STATS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ownname&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SHOP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tabname&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ORDERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;method_opt&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'FOR COLUMNS SIZE 254 STATUS'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- build the histogram it was missing&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same query again, pull the same &lt;code&gt;ALLSTATS LAST&lt;/code&gt; plan, and the guilty line has changed its mind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--------------------------------------------------------------------------------------------------
| Id | Operation                            | Name          | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
--------------------------------------------------------------------------------------------------
|  0 | SELECT STATEMENT                     |               |      1 |        |      1 |00:00:00.01 |      10 |
|  1 |  SORT AGGREGATE                      |               |      1 |      1 |      1 |00:00:00.01 |      10 |
|  2 |   TABLE ACCESS BY INDEX ROWID BATCHED| ORDERS        |      1 |    500 |    500 |00:00:00.01 |      10 |
|* 3 |    INDEX RANGE SCAN                  | ORD_STATUS_IX |      1 |    500 |    500 |00:00:00.01 |       4 |
--------------------------------------------------------------------------------------------------
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;strong&gt;E-Rows is 500 and A-Rows is 500.&lt;/strong&gt; The estimate matches reality, so the optimizer reaches for the index on its own — no hint, no forcing — and Buffers collapse from 8,616 to 10. (The elapsed times barely move, because a million cached rows scan fast — which is exactly why you read Buffers, not the clock. On a table too big to sit in memory, that Buffers gap is the difference between milliseconds and minutes.) You didn't tune the query. You corrected the one number it was wrong about, and the right plan fell out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5gs3zbf8fy230sgdkgdc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5gs3zbf8fy230sgdkgdc.png" alt="One loop, not a hundred knobs: get the real plan, find the line where estimate and reality" width="800" height="905"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One loop, not a hundred knobs: get the real plan, find the line where estimate and reality part ways, fix the reason the estimate was wrong — not the symptom the optimizer picked because of it.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't take my word for it — run it.&lt;/strong&gt; The &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/execution-plans" rel="noopener noreferrer"&gt;execution-plans lab&lt;/a&gt; stands up an Oracle Database Free container, builds a million-row table where &lt;code&gt;OPEN&lt;/code&gt; is deliberately rare, and gathers stats &lt;em&gt;without&lt;/em&gt; a histogram. It runs the query, captures the real &lt;code&gt;ALLSTATS LAST&lt;/code&gt; plan, and &lt;strong&gt;asserts the misestimate is there&lt;/strong&gt; — a full scan with E-Rows a thousandfold over A-Rows. Then it gathers the histogram, re-runs, and &lt;strong&gt;asserts the plan flipped&lt;/strong&gt; to an index range scan with E-Rows ≈ A-Rows. If the misestimate doesn't reproduce, or the fix doesn't correct it, the run fails. The whole before/after is proven on every CI push, not asserted in prose.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What teams get wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tuning the estimate-only plan.&lt;/strong&gt; &lt;code&gt;EXPLAIN PLAN&lt;/code&gt; shows a guess and sometimes not even the guess that runs. Tune the plan the database &lt;em&gt;actually executed&lt;/em&gt; — &lt;code&gt;ALLSTATS LAST&lt;/code&gt; — or you're tuning fiction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading E-Rows and stopping.&lt;/strong&gt; The estimate is the suspect, not the evidence. Its value is only in the gap between it and A-Rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forcing an index with a hint.&lt;/strong&gt; A hint fixes today's value and hides the real defect — a broken estimate — until the next value walks into the same trap. Fix the stats; let the optimizer choose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chasing seconds.&lt;/strong&gt; Elapsed time moves with cache and load. Chase Buffers; that's the work that doesn't lie between runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blaming the optimizer.&lt;/strong&gt; It's not moody. Given honest numbers it makes good choices; given a skewed column with no histogram it makes a bad one for a good reason. Feed it the truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gathering stats blindly and hoping.&lt;/strong&gt; A plain re-gather fixes stale numbers but not skew, not correlation, not a function on a column. Read &lt;em&gt;why&lt;/em&gt; the estimate was wrong, then pick the matching fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the difference between EXPLAIN PLAN and DBMS_XPLAN.DISPLAY_CURSOR?
&lt;/h3&gt;

&lt;p&gt;EXPLAIN PLAN produces the optimizer's estimated plan without executing the statement, and because it does not peek at bind variables it can even show a different plan than the one that actually runs. DBMS_XPLAN.DISPLAY_CURSOR shows the plan for a cursor that really executed, and with the ALLSTATS LAST format it prints actual row counts (A-Rows), start counts, and logical I/O (Buffers) next to the optimizer's estimates (E-Rows). For tuning you want DISPLAY_CURSOR with real execution statistics, because the estimates alone are exactly what tends to be wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I get actual row counts (A-Rows) in an Oracle execution plan?
&lt;/h3&gt;

&lt;p&gt;Either add the /*+ GATHER_PLAN_STATISTICS */ hint to the query, or set STATISTICS_LEVEL = ALL for the session, so Oracle counts real rows as the statement runs. Then display the plan with SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(FORMAT =&amp;gt; 'ALLSTATS LAST')). The output adds A-Rows (actual rows per step), Starts (how many times each step ran), and Buffers (logical I/O) alongside the estimated E-Rows, so you can compare what the optimizer predicted against what actually happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does it mean when E-Rows and A-Rows are very different?
&lt;/h3&gt;

&lt;p&gt;It means the optimizer's cardinality estimate for that step was wrong, and that is usually the root cause of a bad plan. The optimizer chooses join methods, join order, and access paths based on how many rows it expects each step to produce; if it expects five rows and gets five million (or the reverse), every decision above that line was made for the wrong scale. Find the plan line where E-Rows and A-Rows diverge by an order of magnitude or more, and you have found the estimate to fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is Oracle choosing a full table scan instead of my index?
&lt;/h3&gt;

&lt;p&gt;Usually because it estimates your predicate matches a large fraction of the table, which would make a full scan genuinely faster than many single-block index reads. If the estimate is correct, the full scan is the right choice. If the estimate is wrong — often because a skewed column has no histogram, so the optimizer assumes even distribution — fix the estimate rather than forcing the index with a hint. A histogram, fresh statistics, or extended statistics will let the optimizer pick the index on its own for every value, not just the one you tested.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are E-Rows, A-Rows, Starts, and Buffers in DISPLAY_CURSOR output?
&lt;/h3&gt;

&lt;p&gt;E-Rows is the estimated number of rows the optimizer expected a step to return. A-Rows is the actual number it returned at run time. Starts is how many times that step executed — important for nested loops, where the inner step runs once per outer row, so its true output is Starts multiplied by A-Rows. Buffers is logical I/O, the number of buffer accesses the step performed, which is the most reliable measure of work because it does not vary with caching or server load the way elapsed time does.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use a hint to fix a bad execution plan?
&lt;/h3&gt;

&lt;p&gt;Usually not as the first move. A hint that forces an index or a join method fixes the specific case in front of you but masks the underlying cause — typically a wrong cardinality estimate — and can produce a worse plan for a different bind value or as the data grows. Diagnose why the estimate is wrong (stale statistics, missing histogram, correlated columns, a function on a column) and correct that. Then the optimizer makes the right choice for all values. Hints are a last resort or a temporary stabilizer, not the fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a histogram and when do I need one?
&lt;/h3&gt;

&lt;p&gt;A histogram is a statistic that describes how values are distributed within a column, rather than assuming they are spread evenly. You need one when a column is skewed — a few values appear far more or far less often than the rest — and it is used in WHERE-clause predicates. Without a histogram the optimizer divides the row count by the number of distinct values, which badly misestimates selectivity for rare or dominant values. Gather one with DBMS_STATS.GATHER_TABLE_STATS using METHOD_OPT such as FOR COLUMNS SIZE 254 column_name.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do adaptive plans and Real-Time SPM mean I can stop tuning statistics?
&lt;/h3&gt;

&lt;p&gt;No. Adaptive plans (from 12c) can switch join methods at run time when actual row counts contradict the estimate, and Real-Time SQL Plan Management in 23ai can detect a plan regression as it happens and automatically reinstate a known-good plan. Both reduce the blast radius of a bad estimate, but neither makes your statistics accurate — they react to problems rather than prevent them. Correct statistics still produce better first plans across the whole workload; the adaptive features are a safety net beneath that, not a replacement for it.&lt;/p&gt;

&lt;p&gt;Reading a plan is the third skill in the same performance discipline as the other two: an &lt;a href="https://uptimearchitect.com/blog/how-to-read-an-awr-report/" rel="noopener noreferrer"&gt;AWR report&lt;/a&gt; tells you &lt;em&gt;which&lt;/em&gt; SQL is expensive across the whole database, &lt;a href="https://uptimearchitect.com/blog/oracle-wait-events-decoded/" rel="noopener noreferrer"&gt;wait events&lt;/a&gt; tell you &lt;em&gt;what&lt;/em&gt; a session is stuck waiting on, and the execution plan tells you &lt;em&gt;why&lt;/em&gt; a single statement is doing too much work. Start at the top, narrow to the statement, then open the plan and find the one line where the estimate and reality part ways. Prove the whole loop end to end with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/execution-plans" rel="noopener noreferrer"&gt;execution-plans lab&lt;/a&gt; — a misestimate you can watch reproduce, and a histogram you can watch fix it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;uptimearchitect.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>database</category>
      <category>performance</category>
      <category>sql</category>
    </item>
    <item>
      <title>Oracle Unified Auditing Without the Noise</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sun, 09 Aug 2026 21:15:15 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-unified-auditing-without-the-noise-2k3l</link>
      <guid>https://dev.to/uptimearchitect/oracle-unified-auditing-without-the-noise-2k3l</guid>
      <description>&lt;p&gt;Most Oracle audit trails are useless, and they fail in one of two ways. Either nobody ever enabled anything, so when a breach investigation asks "who altered that account," the answer is a shrug. Or someone once turned on &lt;em&gt;everything&lt;/em&gt; — every statement, every user, every object — and the trail became a firehose that filled &lt;code&gt;SYSAUX&lt;/code&gt;, slowed the database, and got quietly ignored until it was disabled again. Neither of those is auditing. One is blindness; the other is noise you can't read.&lt;/p&gt;

&lt;p&gt;The skill isn't auditing &lt;em&gt;more&lt;/em&gt;. It's auditing the &lt;strong&gt;few things that actually matter&lt;/strong&gt; — privilege abuse, credential attacks, changes to your security configuration — in a trail you can trust and actually read. Oracle's &lt;strong&gt;Unified Auditing&lt;/strong&gt; makes that easy, and there's a deadline attached: as of &lt;strong&gt;Oracle Database 23ai, traditional auditing is desupported&lt;/strong&gt;. If your process still leans on &lt;code&gt;AUDIT ...&lt;/code&gt; writing to &lt;code&gt;AUD$&lt;/code&gt;, it has an expiry date. Here's how to do auditing right, and what changes underneath you as you move to 23ai.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short version.&lt;/strong&gt; &lt;strong&gt;Unified Auditing&lt;/strong&gt; (the model since 12c) writes every audit record to a single &lt;strong&gt;protected, read-only internal table&lt;/strong&gt; owned by &lt;code&gt;AUDSYS&lt;/code&gt;, and you turn it on through &lt;strong&gt;policies&lt;/strong&gt;, not statement by statement. Keep the baseline Oracle pre-enables — &lt;strong&gt;&lt;code&gt;ORA_SECURECONFIG&lt;/code&gt;&lt;/strong&gt; (privileged and structural changes) and &lt;strong&gt;&lt;code&gt;ORA_LOGON_FAILURES&lt;/code&gt;&lt;/strong&gt; — then add a &lt;em&gt;few&lt;/em&gt; narrow policies of your own, not a firehose. 19c ships in &lt;strong&gt;mixed mode&lt;/strong&gt; (traditional &lt;code&gt;AUD$&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; unified both active); move to &lt;strong&gt;pure&lt;/strong&gt; unified auditing by relinking the binary (&lt;code&gt;uniaud_on&lt;/code&gt;) and restarting. In &lt;strong&gt;23ai, traditional auditing is gone&lt;/strong&gt; — unified is all there is. Read the trail in &lt;strong&gt;&lt;code&gt;UNIFIED_AUDIT_TRAIL&lt;/code&gt;&lt;/strong&gt;; keep it from growing forever with &lt;strong&gt;&lt;code&gt;DBMS_AUDIT_MGMT&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Unified Auditing actually changed
&lt;/h2&gt;

&lt;p&gt;Before 12c, auditing was a scatter of destinations — &lt;code&gt;AUD$&lt;/code&gt; in the database, &lt;code&gt;FGA_LOG$&lt;/code&gt; for fine-grained policies, OS files, XML files, the &lt;code&gt;SYS&lt;/code&gt; operations audit — each configured differently and each with its own gaps. Unified Auditing collapses all of it into &lt;strong&gt;one trail&lt;/strong&gt; and one way to configure it. Three properties make it worth adopting on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A single, protected trail.&lt;/strong&gt; Every record lands in a read-only internal table in the &lt;strong&gt;&lt;code&gt;AUDSYS&lt;/code&gt;&lt;/strong&gt; schema. You cannot &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, or &lt;code&gt;DELETE&lt;/code&gt; it with SQL — not even as &lt;code&gt;SYS&lt;/code&gt;. An attacker who compromises a privileged account can't quietly scrub their tracks the way they could when audit rows lived in a normal table. You read it through the &lt;strong&gt;&lt;code&gt;UNIFIED_AUDIT_TRAIL&lt;/code&gt;&lt;/strong&gt; view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy-based, not statement-by-statement.&lt;/strong&gt; You define &lt;strong&gt;audit policies&lt;/strong&gt; — named bundles of actions, privileges, or roles to watch — and enable them, optionally scoped to specific users. It's declarative and far easier to review than a pile of individual &lt;code&gt;AUDIT&lt;/code&gt; statements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better performance, by default.&lt;/strong&gt; Unified records are written in &lt;strong&gt;queued (asynchronous) mode&lt;/strong&gt;: they buffer in the SGA and flush to disk in batches, rather than forcing a write on every audited action. (That buffering has one practical consequence for the impatient — see "reading the trail" below.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The baseline is already on — don't turn it off
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprises people: a modern Oracle database is &lt;strong&gt;already auditing the right security-critical actions&lt;/strong&gt;, because Oracle pre-enables a sensible baseline. Two policies do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ORA_SECURECONFIG&lt;/code&gt;&lt;/strong&gt; — audits the actions that change your security posture: &lt;code&gt;CREATE&lt;/code&gt;/&lt;code&gt;ALTER&lt;/code&gt;/&lt;code&gt;DROP USER&lt;/code&gt;, &lt;code&gt;GRANT&lt;/code&gt;, &lt;code&gt;REVOKE&lt;/code&gt;, &lt;code&gt;ALTER SYSTEM&lt;/code&gt;, &lt;code&gt;CREATE&lt;/code&gt;/&lt;code&gt;DROP&lt;/code&gt; of directories and database links, and more. This is enabled by default from 12.2 onward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ORA_LOGON_FAILURES&lt;/code&gt;&lt;/strong&gt; — audits failed logons, which is exactly the fingerprint of a credential attack or a misconfigured application hammering the listener.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check what's actually enabled right now — the first query every audit review should start with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;policy_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enabled_option&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;audit_unified_enabled_policies&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;policy_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;ORA_SECURECONFIG&lt;/code&gt; isn't in that list, someone disabled it, and &lt;em&gt;that&lt;/em&gt; is your finding — a database that isn't recording who changes its own security is a database you can't investigate. The baseline is the floor, not the ceiling, but it is a floor most estates already have for free. Don't rip it out in the name of "reducing overhead"; the overhead is negligible and the coverage is exactly the high-value set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit the few things that are specific to you
&lt;/h2&gt;

&lt;p&gt;On top of the baseline, add narrow policies for the things that matter in &lt;em&gt;your&lt;/em&gt; database — a sensitive table, a powerful privilege, a specific service account. This is where discipline pays: every policy you add is a policy someone has to read, so add few and make them sharp.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Watch reads of one sensitive table (and who did them)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;AUDIT&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;aud_salary_access&lt;/span&gt;
  &lt;span class="n"&gt;ACTIONS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;hr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_salary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;AUDIT&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;aud_salary_access&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Watch any use of a skeleton-key system privilege&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;AUDIT&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;aud_any_table&lt;/span&gt;
  &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;AUDIT&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;aud_any_table&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Policies can be scoped and conditioned so they capture signal, not volume: &lt;code&gt;AUDIT POLICY aud_x BY app_user&lt;/code&gt; limits it to one account, &lt;code&gt;... WHENEVER NOT SUCCESSFUL&lt;/code&gt; records only the &lt;em&gt;failed&lt;/em&gt; attempts (often the interesting ones), and an &lt;code&gt;audit_condition&lt;/code&gt; on a policy lets you fire only when a column or context matches. The instinct to "audit all DML on everything" is the firehose again — resist it. A handful of targeted policies plus the baseline is a trail a human will actually read during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mixed mode, pure mode, and the 23ai cliff
&lt;/h2&gt;

&lt;p&gt;Whether unified auditing is the &lt;em&gt;only&lt;/em&gt; audit system running depends on your release, and this trips people up during upgrades.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;12c–19c ship in &lt;em&gt;mixed mode&lt;/em&gt;.&lt;/strong&gt; Both the old traditional auditing (&lt;code&gt;AUDIT&lt;/code&gt; → &lt;code&gt;AUD$&lt;/code&gt;) &lt;em&gt;and&lt;/em&gt; unified auditing are active. Mixed mode exists so you can adopt unified auditing gradually without breaking legacy scripts. It also means your audit data is split across two systems — the thing unified auditing was supposed to end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pure unified auditing&lt;/strong&gt; turns traditional auditing off entirely. Moving to it isn't a &lt;code&gt;SET&lt;/code&gt; — you &lt;strong&gt;relink the Oracle binary&lt;/strong&gt; with unified auditing on and restart the instance:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- database down, then from $ORACLE_HOME/rdbms/lib:
make -f ins_rdbms.mk uniaud_on ioracle ORACLE_HOME=$ORACLE_HOME
-- restart; it's reversible with uniaud_off if you must go back
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;23ai removes the choice.&lt;/strong&gt; As of &lt;strong&gt;Oracle Database 23ai, traditional auditing is desupported.&lt;/strong&gt; A freshly created 23ai database has no traditional audit at all — unified auditing is the audit system. On an &lt;em&gt;upgrade&lt;/em&gt;, any traditional audit settings you were still using are carried forward for compatibility, but the direction is one-way: new work goes to unified, and the old model is on its way out. The practical takeaway — &lt;strong&gt;if your compliance runbook still says "&lt;code&gt;AUDIT&lt;/code&gt; this, check &lt;code&gt;AUD$&lt;/code&gt;," it needs rewriting before you reach 23ai&lt;/strong&gt;, not after.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv7qfydnsn3y9jttqkgjn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv7qfydnsn3y9jttqkgjn.png" alt="Getting Unified Auditing right is three decisions, not a hundred: keep the baseline, add a" width="800" height="1691"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Getting Unified Auditing right is three decisions, not a hundred: keep the baseline, add a few sharp policies, and know which audit mode your release actually runs.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Prove your policies actually capture what you think.&lt;/strong&gt; The &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/unified-auditing" rel="noopener noreferrer"&gt;unified-auditing lab&lt;/a&gt; stands up an Oracle Database Free container, enables the baseline plus a custom policy on a sensitive table, then &lt;em&gt;triggers&lt;/em&gt; the real events — a failed login, a &lt;code&gt;GRANT&lt;/code&gt;, a read of the protected table — flushes the audit buffer, and queries &lt;code&gt;UNIFIED_AUDIT_TRAIL&lt;/code&gt; to &lt;strong&gt;assert every one was recorded&lt;/strong&gt;. If a policy silently isn't capturing, the run fails. It's the difference between "we have auditing" and "we checked."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reading and keeping the trail
&lt;/h2&gt;

&lt;p&gt;A trail you never read is theater. Reading unified auditing is one view — &lt;code&gt;UNIFIED_AUDIT_TRAIL&lt;/code&gt; — filtered by what you care about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- failed logons in the last day (credential-attack fingerprint)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;event_timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbusername&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userhost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_code&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;unified_audit_trail&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;action_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'LOGON'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;return_code&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt;    &lt;span class="n"&gt;event_timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SYSTIMESTAMP&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;event_timestamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two operational details keep this working. First, because records are written in &lt;strong&gt;queued mode&lt;/strong&gt;, the newest ones may still be in the SGA buffer, not yet in the view — force them out with &lt;code&gt;DBMS_AUDIT_MGMT.FLUSH_UNIFIED_AUDIT_TRAIL&lt;/code&gt; before you trust "nothing's there" (the lab does exactly this before it checks). Second, the trail &lt;strong&gt;grows forever unless you manage it&lt;/strong&gt;: set a retention window and a scheduled purge with &lt;strong&gt;&lt;code&gt;DBMS_AUDIT_MGMT&lt;/code&gt;&lt;/strong&gt; so the audit table doesn't quietly consume your tablespace.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- keep 180 days, then schedule automatic cleanup&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_AUDIT_MGMT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SET_LAST_ARCHIVE_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;audit_trail_type&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_AUDIT_MGMT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AUDIT_TRAIL_UNIFIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;last_archive_time&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SYSTIMESTAMP&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'180'&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_AUDIT_MGMT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CREATE_PURGE_JOB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;audit_trail_type&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_AUDIT_MGMT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AUDIT_TRAIL_UNIFIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;audit_trail_purge_interval&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;audit_trail_purge_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'unified_purge'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;use_last_arch_timestamp&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What teams get wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auditing everything.&lt;/strong&gt; "Audit all actions on all objects" is the firehose that gets muted and then disabled. Enable the baseline, add a few sharp policies, and review them. Coverage you never read is worse than none — it's cost with no benefit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turning off the baseline "for performance."&lt;/strong&gt; &lt;code&gt;ORA_SECURECONFIG&lt;/code&gt; is cheap and its coverage is exactly the security-critical actions. Disabling it is how a database loses the ability to explain who changed its own configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming traditional auditing still works forever.&lt;/strong&gt; It's desupported in 23ai. A runbook built on &lt;code&gt;AUDIT&lt;/code&gt; statements and &lt;code&gt;AUD$&lt;/code&gt; queries breaks on the upgrade you haven't scheduled yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Living in mixed mode by accident.&lt;/strong&gt; On 19c, "we use unified auditing" is often only half true — the old system is still running alongside it, splitting your trail. Decide to go pure, and relink.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting to purge.&lt;/strong&gt; The unified trail grows without bound. No retention policy means an audit table that eventually pressures &lt;code&gt;SYSAUX&lt;/code&gt;/its tablespace — set a &lt;code&gt;DBMS_AUDIT_MGMT&lt;/code&gt; purge job on day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not flushing before you conclude "nothing happened."&lt;/strong&gt; Queued-mode records lag. Flush the buffer before you trust an empty result during an investigation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one-paragraph version
&lt;/h2&gt;

&lt;p&gt;Auditing isn't about volume, it's about signal. Use &lt;strong&gt;Unified Auditing&lt;/strong&gt; — one protected, read-only trail in &lt;code&gt;AUDSYS&lt;/code&gt;, configured by policies. Keep the baseline Oracle already enabled (&lt;strong&gt;&lt;code&gt;ORA_SECURECONFIG&lt;/code&gt;&lt;/strong&gt; + &lt;strong&gt;&lt;code&gt;ORA_LOGON_FAILURES&lt;/code&gt;&lt;/strong&gt;) and add a &lt;em&gt;few&lt;/em&gt; narrow policies for your sensitive tables and powerful privileges — never a firehose. Know your mode: &lt;strong&gt;19c runs mixed&lt;/strong&gt; (traditional and unified together), you move to &lt;strong&gt;pure&lt;/strong&gt; by relinking &lt;code&gt;uniaud_on&lt;/code&gt; and restarting, and &lt;strong&gt;23ai desupports traditional auditing outright&lt;/strong&gt;, so rewrite any &lt;code&gt;AUD$&lt;/code&gt;-based runbook before you get there. Read it through &lt;strong&gt;&lt;code&gt;UNIFIED_AUDIT_TRAIL&lt;/code&gt;&lt;/strong&gt; (flush the buffer first), and keep it bounded with a &lt;strong&gt;&lt;code&gt;DBMS_AUDIT_MGMT&lt;/code&gt;&lt;/strong&gt; purge job. A handful of policies you actually read beats a trail of everything you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the difference between traditional and Unified Auditing in Oracle?
&lt;/h3&gt;

&lt;p&gt;Traditional auditing (the AUDIT command writing to SYS.AUD$, plus separate destinations for fine-grained auditing, OS files, and SYS operations) is configured statement by statement and scattered across multiple trails. Unified Auditing, the model since Oracle 12c, consolidates everything into a single protected, read-only internal table in the AUDSYS schema and is configured through named policies you enable or disable as a unit. Unified Auditing is easier to manage, harder to tamper with, and higher performance because records are written asynchronously. As of Oracle Database 23ai, traditional auditing is desupported.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Unified Auditing enabled by default in Oracle?
&lt;/h3&gt;

&lt;p&gt;Partly. Unified Auditing as a system is available in every 12c and later database, and Oracle pre-enables a baseline of predefined policies — most importantly ORA_SECURECONFIG (security-relevant actions such as CREATE/ALTER USER, GRANT, and ALTER SYSTEM) from 12.2 onward, and ORA_LOGON_FAILURES for failed logons. However, in 12c through 19c the database runs in mixed mode, meaning traditional auditing is still active alongside unified auditing. Running pure unified auditing (unified only) requires relinking the Oracle binary and restarting.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I enable pure Unified Auditing?
&lt;/h3&gt;

&lt;p&gt;Pure Unified Auditing is enabled by relinking the Oracle executable with unified auditing turned on, not by a parameter. Shut the database down, then from $ORACLE_HOME/rdbms/lib run: make -f ins_rdbms.mk uniaud_on ioracle ORACLE_HOME=$ORACLE_HOME, and restart the instance. After that, traditional auditing is disabled and only unified auditing records are produced. The change is reversible by relinking with uniaud_off. In Oracle Database 23ai this is moot because traditional auditing is desupported.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where are Unified Audit records stored and can they be modified?
&lt;/h3&gt;

&lt;p&gt;Unified audit records are stored in a read-only internal table owned by the AUDSYS schema, and you read them through the UNIFIED_AUDIT_TRAIL view. They cannot be changed with ordinary SQL — INSERT, UPDATE, and DELETE against the trail are not permitted, even for SYS. Records are managed only through the DBMS_AUDIT_MGMT package, which is used to archive and purge old records. This protection is a core reason to prefer unified auditing: a compromised privileged account cannot silently erase its own audit trail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do my new audit records not appear in UNIFIED_AUDIT_TRAIL immediately?
&lt;/h3&gt;

&lt;p&gt;Because unified audit records are written in queued (asynchronous) mode by default: they buffer in the SGA and are flushed to the audit table in batches for performance, so the most recent actions can lag before they appear in the view. To force them out immediately — for example during an investigation before concluding that nothing was recorded — run DBMS_AUDIT_MGMT.FLUSH_UNIFIED_AUDIT_TRAIL, then re-query. This buffering is normal and is the tradeoff that keeps auditing cheap.&lt;/p&gt;

&lt;h3&gt;
  
  
  What audit policies should I actually enable?
&lt;/h3&gt;

&lt;p&gt;Keep the baseline Oracle already enables — ORA_SECURECONFIG for security-configuration changes and ORA_LOGON_FAILURES for failed logons — and add a small number of narrow policies for what matters in your database: access to a specific sensitive table, use of powerful ANY privileges, or activity by a particular service account. Scope and condition them (BY user, WHENEVER NOT SUCCESSFUL, or an audit_condition) so they capture signal rather than volume. Auditing everything produces a trail nobody reads; a handful of sharp policies plus the baseline is what actually helps in an incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop the unified audit trail from growing forever?
&lt;/h3&gt;

&lt;p&gt;Use the DBMS_AUDIT_MGMT package to set a retention window and a scheduled purge for the unified audit trail. Set a last-archive timestamp with DBMS_AUDIT_MGMT.SET_LAST_ARCHIVE_TIMESTAMP and create a recurring cleanup with DBMS_AUDIT_MGMT.CREATE_PURGE_JOB for audit_trail_type AUDIT_TRAIL_UNIFIED. Without a purge policy the audit table grows without bound and eventually pressures its tablespace. Decide a retention period that satisfies your compliance requirement and automate the cleanup from the start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is traditional auditing really gone in Oracle 23ai?
&lt;/h3&gt;

&lt;p&gt;Traditional auditing is desupported in Oracle Database 23ai. A newly created 23ai database has no traditional auditing; unified auditing is the audit system. If you upgrade a database that still had traditional audit settings, those settings are carried forward for compatibility, but new auditing work uses unified auditing and the traditional model should not be relied on going forward. Any process, script, or compliance runbook built on the AUDIT command and the AUD$ table should be migrated to unified auditing before adopting 23ai.&lt;/p&gt;

&lt;p&gt;Auditing is the third leg of the security-and-ops discipline, alongside &lt;a href="https://uptimearchitect.com/blog/oracle-patching-cpu-ru-rur/" rel="noopener noreferrer"&gt;patching&lt;/a&gt; and &lt;a href="https://uptimearchitect.com/blog/oracle-database-hardening-checklist/" rel="noopener noreferrer"&gt;hardening&lt;/a&gt; — patching closes known vulnerabilities, hardening closes the configuration gaps, and auditing is how you &lt;em&gt;know&lt;/em&gt; when either one is being tested. Do it the same way you do the other two: not exhaustively, but deliberately — the few high-value policies, enabled, purged, and actually read. Prove yours capture what you think with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/unified-auditing" rel="noopener noreferrer"&gt;unified-auditing lab&lt;/a&gt;, and put the review on the same cadence as your quarterly patch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-unified-auditing/" rel="noopener noreferrer"&gt;uptimearchitect.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>auditing</category>
      <category>oraclesecurity</category>
      <category>unifiedauditing</category>
    </item>
    <item>
      <title>The Oracle Hardening Checklist That Actually Matters</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sat, 01 Aug 2026 22:15:45 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/the-oracle-hardening-checklist-that-actually-matters-eac</link>
      <guid>https://dev.to/uptimearchitect/the-oracle-hardening-checklist-that-actually-matters-eac</guid>
      <description>&lt;p&gt;Open any Oracle security benchmark and you'll find two hundred–plus line items, each with a rationale, a check, and a remediation. It is thorough, it is auditable, and it is where most hardening projects go to die — three weeks of setting obscure parameters that shave a rounding error off your risk while the two gaps that actually get databases breached sit untouched because they weren't near the top of the alphabet.&lt;/p&gt;

&lt;p&gt;Hardening is not a completeness exercise. A short list of controls stops the overwhelming majority of real incidents: accounts that still have their default password, privileges handed out for convenience, a listener open to the world, unencrypted data at rest, and no audit trail to notice any of it. Here's that list — in priority order, each with &lt;em&gt;why it matters&lt;/em&gt;, &lt;em&gt;how it actually gets exploited&lt;/em&gt;, and &lt;em&gt;the one query or command that tells you where you stand&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short version.&lt;/strong&gt; Do these six, roughly in order. &lt;strong&gt;(1)&lt;/strong&gt; Find and close accounts with default passwords — &lt;code&gt;DBA_USERS_WITH_DEFPWD&lt;/code&gt; names them for you. &lt;strong&gt;(2)&lt;/strong&gt; Take back what you granted for convenience: &lt;code&gt;EXECUTE&lt;/code&gt; on the network packages (&lt;code&gt;UTL_HTTP&lt;/code&gt;, &lt;code&gt;UTL_TCP&lt;/code&gt;, &lt;code&gt;UTL_SMTP&lt;/code&gt;) from &lt;strong&gt;PUBLIC&lt;/strong&gt;, any &lt;code&gt;%ANY%&lt;/code&gt; system privilege, and &lt;code&gt;DBA&lt;/code&gt; on application accounts. &lt;strong&gt;(3)&lt;/strong&gt; Lock the listener — valid-node checking, admin restrictions, and never on the public internet. &lt;strong&gt;(4)&lt;/strong&gt; Turn on &lt;strong&gt;TDE&lt;/strong&gt; so a stolen datafile or backup is useless. &lt;strong&gt;(5)&lt;/strong&gt; Give the &lt;code&gt;DEFAULT&lt;/code&gt; profile a &lt;strong&gt;failed-login lockout&lt;/strong&gt; and a password verify function. &lt;strong&gt;(6)&lt;/strong&gt; Enable &lt;strong&gt;Unified Auditing&lt;/strong&gt; for the &lt;em&gt;few&lt;/em&gt; things worth watching (&lt;code&gt;ORA_SECURECONFIG&lt;/code&gt; + &lt;code&gt;ORA_LOGON_FAILURES&lt;/code&gt;), not everything. Everything past this is refinement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Kill the default passwords
&lt;/h2&gt;

&lt;p&gt;This is number one because it is the single most reliably exploited weakness in an Oracle estate, and it is free to fix. Every attacker's first move against an Oracle listener is to try known account/password pairs — &lt;code&gt;SYSTEM/manager&lt;/code&gt;, &lt;code&gt;DBSNMP/dbsnmp&lt;/code&gt;, &lt;code&gt;SCOTT/tiger&lt;/code&gt;, an app account whose password equals its username. It requires no exploit, no CVE, nothing but a dictionary.&lt;/p&gt;

&lt;p&gt;You do not have to guess which of your accounts are exposed, because Oracle ships a view that checks the password hashes against its own list of known defaults:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_status&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dba_users_with_defpwd&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row is an account an attacker can walk into. For each one: if you don't need it, &lt;strong&gt;lock and expire&lt;/strong&gt; it; if you do, give it a real password. Sample schemas (&lt;code&gt;HR&lt;/code&gt;, &lt;code&gt;OE&lt;/code&gt;, &lt;code&gt;SCOTT&lt;/code&gt;) shouldn't be in a production database at all — they're not installed by default anymore, so if they're present, someone added them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;scott&lt;/span&gt;  &lt;span class="n"&gt;ACCOUNT&lt;/span&gt; &lt;span class="k"&gt;LOCK&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="n"&gt;EXPIRE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- don't need it → close it&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;dbsnmp&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;a real secret&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- need it → stop using the default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While you're here, look at what's simply &lt;em&gt;open and unused&lt;/em&gt;: &lt;code&gt;SELECT username FROM dba_users WHERE account_status = 'OPEN'&lt;/code&gt; and lock every account no human or application actually logs in as. An account that can't authenticate can't be the way in.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Least privilege: PUBLIC, the ANY privileges, and DBA-for-convenience
&lt;/h2&gt;

&lt;p&gt;Almost every over-privilege problem in Oracle traces back to a grant someone made to &lt;em&gt;stop being asked about it&lt;/em&gt;. Three of them do most of the damage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;PUBLIC&lt;/code&gt; means every user, including the one an attacker just compromised.&lt;/strong&gt; Historically Oracle granted &lt;code&gt;EXECUTE&lt;/code&gt; on the network and file packages — &lt;code&gt;UTL_HTTP&lt;/code&gt;, &lt;code&gt;UTL_TCP&lt;/code&gt;, &lt;code&gt;UTL_SMTP&lt;/code&gt;, &lt;code&gt;UTL_INADDR&lt;/code&gt;, &lt;code&gt;UTL_FILE&lt;/code&gt; — to &lt;code&gt;PUBLIC&lt;/code&gt;. Those packages let PL/SQL open outbound network connections and read files; in the hands of a low-privilege account that's been popped, they're an exfiltration and lateral-movement kit. Find what &lt;code&gt;PUBLIC&lt;/code&gt; can execute that it shouldn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;privilege&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;dba_tab_privs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;grantee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PUBLIC'&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt;    &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UTL_HTTP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'UTL_TCP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'UTL_SMTP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'UTL_INADDR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'UTL_FILE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'DBMS_LOB'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revoke from &lt;code&gt;PUBLIC&lt;/code&gt; and grant back to the &lt;em&gt;specific&lt;/em&gt; schemas that genuinely need it — usually far fewer than you expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;ANY&lt;/code&gt; privileges are a skeleton key.&lt;/strong&gt; &lt;code&gt;SELECT ANY TABLE&lt;/code&gt;, &lt;code&gt;EXECUTE ANY PROCEDURE&lt;/code&gt;, &lt;code&gt;ALTER ANY …&lt;/code&gt; — each one crosses every schema boundary in the database. They get granted to application accounts and reporting users because it was easier than enumerating objects. List the ones held by accounts &lt;em&gt;you&lt;/em&gt; created (Oracle's own maintained accounts legitimately hold some):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;grantee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;privilege&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;dba_sys_privs&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dba_users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;grantee&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oracle_maintained&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'N'&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt;    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;privilege&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%ANY%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;DBA&lt;/code&gt; (or &lt;code&gt;PDB_DBA&lt;/code&gt;) on an application account&lt;/strong&gt; is the same mistake wearing a role. An app should own its objects and hold exactly the privileges its code needs — never the role that can read, alter, and drop everything. Check with &lt;code&gt;DBA_ROLE_PRIVS&lt;/code&gt; filtered the same way, and replace the role with a tailored one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Lock the front door: the listener and the network
&lt;/h2&gt;

&lt;p&gt;The database can be perfectly hardened and still be trivially reachable if the listener in front of it isn't. Three controls matter more than the rest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Valid-node checking.&lt;/strong&gt; Restrict which hosts may even &lt;em&gt;connect&lt;/em&gt; to the listener with &lt;code&gt;TCP.VALIDNODE_CHECKING&lt;/code&gt;, &lt;code&gt;TCP.INVITED_NODES&lt;/code&gt;, and &lt;code&gt;TCP.EXCLUDED_NODES&lt;/code&gt; in &lt;code&gt;sqlnet.ora&lt;/code&gt;. It's an allow-list at the network layer, before authentication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin restrictions.&lt;/strong&gt; Set &lt;code&gt;ADMIN_RESTRICTIONS_&amp;lt;listener&amp;gt; = ON&lt;/code&gt; so nobody can reconfigure the listener remotely at runtime, and make sure it requires OS authentication for admin — a listener you can &lt;code&gt;set&lt;/code&gt; remotely is a listener an attacker can &lt;code&gt;set&lt;/code&gt; remotely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never on the public internet.&lt;/strong&gt; A database listener on &lt;code&gt;0.0.0.0:1521&lt;/code&gt; reachable from outside your network is the finding behind a large share of Oracle compromises. It belongs on a private subnet, behind a security group that allows only the application tier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can encrypt the client-to-database traffic, do — either native network encryption in &lt;code&gt;sqlnet.ora&lt;/code&gt; or TCPS/TLS. An unencrypted connection is credentials and data in cleartext on the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Encrypt at rest: TDE
&lt;/h2&gt;

&lt;p&gt;Everything above protects the &lt;em&gt;running&lt;/em&gt; database. &lt;strong&gt;Transparent Data Encryption&lt;/strong&gt; protects it when it isn't running — when a datafile, a backup piece, or a decommissioned disk leaves the building. Without TDE, a stolen datafile or RMAN backup is just a file an attacker &lt;code&gt;strings&lt;/code&gt;; with it, it's noise without the keystore.&lt;/p&gt;

&lt;p&gt;TDE encrypts at the tablespace (or column) level and is transparent to the application — no SQL changes. You set up a keystore once, then encrypt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- one-time: configure and open a keystore (WALLET_ROOT), then:&lt;/span&gt;
&lt;span class="n"&gt;ADMINISTER&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;MANAGEMENT&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="n"&gt;KEYSTORE&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;pwd&amp;gt;"&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;TABLESPACE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;ENCRYPTION&lt;/span&gt; &lt;span class="n"&gt;ONLINE&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="s1"&gt;'AES256'&lt;/span&gt; &lt;span class="n"&gt;ENCRYPT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New tablespaces should be encrypted from creation. And TDE is what makes an RMAN backup safe to store off-site — pair it with the recovery discipline in the &lt;a href="https://uptimearchitect.com/blog/oracle-rman-recovery-runbook/" rel="noopener noreferrer"&gt;RMAN Recovery Runbook&lt;/a&gt; so the backup you can restore is also the backup you don't have to worry about losing.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Password profiles: make brute force expensive
&lt;/h2&gt;

&lt;p&gt;Default-password hygiene (item 1) closes the &lt;em&gt;known&lt;/em&gt; passwords. A &lt;strong&gt;failed-login lockout&lt;/strong&gt; closes the guessable ones by making an online brute-force attack die after a handful of tries. This is one &lt;code&gt;ALTER PROFILE&lt;/code&gt; on the &lt;code&gt;DEFAULT&lt;/code&gt; profile — which nearly every account inherits — and it's often left wide open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;resource_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;limit&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;dba_profiles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'DEFAULT'&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt;    &lt;span class="n"&gt;resource_name&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'FAILED_LOGIN_ATTEMPTS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'PASSWORD_LIFE_TIME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'PASSWORD_VERIFY_FUNCTION'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;FAILED_LOGIN_ATTEMPTS&lt;/code&gt; reads &lt;code&gt;UNLIMITED&lt;/code&gt;, an attacker can guess forever. Set a finite lockout, a password lifetime, and attach a verify function so weak passwords can't be set in the first place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;PROFILE&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt;
  &lt;span class="n"&gt;FAILED_LOGIN_ATTEMPTS&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
  &lt;span class="n"&gt;PASSWORD_LIFE_TIME&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;
  &lt;span class="n"&gt;PASSWORD_VERIFY_FUNCTION&lt;/span&gt; &lt;span class="n"&gt;ora12c_strong_verify_function&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Balance the lockout against your reality: too aggressive a threshold plus a shared service account is a self-inflicted denial of service. Ten is a sane default; service accounts should use long, rotated secrets rather than relying on lockout at all.)&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Audit the few things worth auditing
&lt;/h2&gt;

&lt;p&gt;The reason most Oracle databases have no useful audit trail is that "audit everything" produces a firehose nobody reads, so eventually someone turns it off. &lt;strong&gt;Unified Auditing&lt;/strong&gt; — the model since 12c, which writes to a protected internal table instead of OS files — makes selective, low-noise auditing easy through &lt;em&gt;policies&lt;/em&gt;. You don't need dozens; you need the two that catch privilege abuse and credential attacks, which Oracle pre-defines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- what's actually enabled right now:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;policy_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enabled_option&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;audit_unified_enabled_policies&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;policy_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- the sensible baseline:&lt;/span&gt;
&lt;span class="n"&gt;AUDIT&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;ora_secureconfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;-- privileged actions, structural changes (on by default — keep it)&lt;/span&gt;
&lt;span class="n"&gt;AUDIT&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;ora_logon_failures&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- failed logins: the brute force from item 5, recorded&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ORA_SECURECONFIG&lt;/code&gt; is enabled by default in a modern database — the failure mode is someone having turned it &lt;em&gt;off&lt;/em&gt;. From that baseline, add narrow policies for the things specific to you (access to a sensitive table, use of a powerful role) rather than broad ones. If you're still on the legacy &lt;code&gt;AUDIT&lt;/code&gt;/&lt;code&gt;AUD$&lt;/code&gt; model, plan the move to pure Unified Auditing — it's faster and its trail can't be tampered with from outside the database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ck318neapo6znmu2u06.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ck318neapo6znmu2u06.png" alt="Oracle database hardening in priority order — the six controls, most impactful first" width="800" height="2872"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Hardening in priority order: each step closes a class of real attack. Do them top to bottom — a later control on an unhardened account is polish on a door that's already open.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Run the checklist against a live database.&lt;/strong&gt; The &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/hardening" rel="noopener noreferrer"&gt;hardening-audit lab&lt;/a&gt; stands up an Oracle Database Free container in a deliberately weak state, runs &lt;code&gt;./run.sh audit&lt;/code&gt; to score it against these controls — default passwords, &lt;code&gt;PUBLIC&lt;/code&gt;/&lt;code&gt;ANY&lt;/code&gt; grants, &lt;code&gt;DBA&lt;/code&gt;-on-app-accounts, the failed-login profile, and the auditing baseline — then &lt;code&gt;./run.sh harden&lt;/code&gt; fixes them and re-audits to &lt;strong&gt;prove&lt;/strong&gt; every check flips from FAIL to PASS. The CI matrix runs the whole cycle on every push, so the numbers aren't a claim, they're a test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What teams get wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chasing the whole benchmark.&lt;/strong&gt; Two hundred controls sorted by document order means the critical five compete for attention with the trivial. Prioritize by exploitability, not by checklist completeness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auditing everything, reading nothing.&lt;/strong&gt; A firehose gets muted. Enable the two baseline policies plus a few targeted ones, and actually review them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving default and unused accounts open.&lt;/strong&gt; &lt;code&gt;DBA_USERS_WITH_DEFPWD&lt;/code&gt; and &lt;code&gt;account_status = 'OPEN'&lt;/code&gt; take one query each. There is no excuse for an open account with a known password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granting &lt;code&gt;DBA&lt;/code&gt; (or &lt;code&gt;%ANY%&lt;/code&gt;) for convenience.&lt;/strong&gt; The app that "just needs it to work" becomes the account that can read and drop every schema once it's compromised.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating hardening as one-time.&lt;/strong&gt; Grants accumulate, accounts get created, someone disables a policy to debug and forgets. Configuration drifts; re-audit on a cadence, the same way you patch on a cadence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypting nothing because "it's internal."&lt;/strong&gt; Internal networks get breached and backups get lost. TDE is the control that makes those events non-events.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one-paragraph version
&lt;/h2&gt;

&lt;p&gt;Skip the two-hundred-item benchmark and do the six that matter, in order. Close accounts with default passwords (&lt;code&gt;DBA_USERS_WITH_DEFPWD&lt;/code&gt; finds them). Take back convenience grants — &lt;code&gt;EXECUTE&lt;/code&gt; on the network packages from &lt;code&gt;PUBLIC&lt;/code&gt;, every &lt;code&gt;%ANY%&lt;/code&gt; privilege, and &lt;code&gt;DBA&lt;/code&gt; on application accounts. Lock the listener with valid-node checking and admin restrictions, and keep it off the public internet. Turn on &lt;strong&gt;TDE&lt;/strong&gt; so stolen datafiles and backups are useless. Give the &lt;code&gt;DEFAULT&lt;/code&gt; profile a &lt;strong&gt;failed-login lockout&lt;/strong&gt; and a verify function. Enable &lt;strong&gt;Unified Auditing&lt;/strong&gt; for &lt;code&gt;ORA_SECURECONFIG&lt;/code&gt; and &lt;code&gt;ORA_LOGON_FAILURES&lt;/code&gt;, then add narrow policies — not a firehose. Then re-audit on a schedule, because hardening drifts. That list stops the incidents that actually happen; everything else is refinement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the most important first step in Oracle database hardening?
&lt;/h3&gt;

&lt;p&gt;Closing accounts that still have a default or known password. It is the most reliably exploited Oracle weakness and requires no exploit — an attacker simply tries known username/password pairs against the listener. Oracle provides the DBA_USERS_WITH_DEFPWD view, which lists every account whose password matches a known default; lock and expire the ones you do not need and set real passwords on the ones you do. It costs one query and a few ALTER USER statements and removes the easiest way in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I revoke EXECUTE on packages from PUBLIC?
&lt;/h3&gt;

&lt;p&gt;For the network and file packages — UTL_HTTP, UTL_TCP, UTL_SMTP, UTL_INADDR, UTL_FILE — yes, in most environments. Granted to PUBLIC, they let any authenticated account (including a compromised low-privilege one) open outbound network connections or read files, which is an exfiltration and lateral-movement capability. Revoke EXECUTE from PUBLIC and grant it back only to the specific schemas that genuinely require it. Test first, because application code occasionally relies on these grants.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between traditional and Unified Auditing in Oracle?
&lt;/h3&gt;

&lt;p&gt;Traditional auditing (the legacy AUDIT command writing to SYS.AUD$ or to OS files) is configured statement by statement and can be verbose and hard to protect. Unified Auditing, the model since Oracle 12c, consolidates all audit data into a single protected, read-only internal table and is configured through policies you enable or disable as a unit. Unified Auditing is faster, harder to tamper with, and easier to keep low-noise. Oracle recommends moving to pure Unified Auditing; enable the ORA_SECURECONFIG and ORA_LOGON_FAILURES policies as a baseline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does hardening an Oracle database require downtime?
&lt;/h3&gt;

&lt;p&gt;Most of it does not. Locking accounts, revoking privileges, altering the DEFAULT profile, and enabling Unified Auditing policies are all online operations. Encrypting existing tablespaces with TDE can be done online in current releases, though the initial keystore configuration may involve a restart depending on how WALLET_ROOT is set. Listener and sqlnet.ora changes take effect on a listener reload, not a database restart. Plan TDE and network-encryption rollouts, but the high-value account and privilege work is all no-outage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Transparent Data Encryption (TDE) necessary if my database is on an internal network?
&lt;/h3&gt;

&lt;p&gt;It protects a different threat than network controls do. Listener and firewall rules protect the running database from unauthorized connections; TDE protects the data when it is at rest and leaves the running system — a stolen or misplaced datafile, an RMAN backup piece copied off-site, a decommissioned disk. Internal networks are breached and backups do get lost, so for any database holding sensitive or regulated data, TDE is the control that turns those events into non-events. It is transparent to the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should I re-audit an Oracle database against a hardening checklist?
&lt;/h3&gt;

&lt;p&gt;On a defined cadence, the same way you patch — quarterly is a reasonable baseline, plus after any significant change. Hardening drifts: privileges get granted for a one-off task and never revoked, accounts get created, someone disables an audit policy to debug an issue and forgets to re-enable it. A one-time hardening project degrades steadily unless you re-check it. Automating the checks (a script or a lab-style audit that reports PASS/FAIL) makes the re-audit cheap enough to actually do.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the ANY privileges and why are they a hardening risk?
&lt;/h3&gt;

&lt;p&gt;The system privileges containing ANY — SELECT ANY TABLE, EXECUTE ANY PROCEDURE, ALTER ANY TABLE, and others — grant an operation across every schema in the database, ignoring object ownership boundaries. They are commonly granted to application or reporting accounts as a shortcut instead of enumerating the specific objects needed. If such an account is compromised, the ANY privilege lets the attacker read, alter, or drop objects anywhere. Find them with DBA_SYS_PRIVS filtered to accounts where ORACLE_MAINTAINED = N, and replace them with grants on the specific objects the account actually uses.&lt;/p&gt;

&lt;p&gt;Hardening and &lt;a href="https://uptimearchitect.com/blog/oracle-patching-cpu-ru-rur/" rel="noopener noreferrer"&gt;patching&lt;/a&gt; are the two halves of the security-and-ops discipline: patching closes the vulnerabilities Oracle tells you about, hardening closes the ones your own configuration opened. Neither is a one-time project — both are a cadence. Start with the six controls above, prove them with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/hardening" rel="noopener noreferrer"&gt;hardening-audit lab&lt;/a&gt;, and put the re-audit on the same calendar as your quarterly Release Update.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-database-hardening-checklist/" rel="noopener noreferrer"&gt;uptimearchitect.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>hardening</category>
      <category>oraclesecurity</category>
      <category>leastprivilege</category>
    </item>
    <item>
      <title>Okroshka, Ice-Cold: I painted a Khokhloma bowl of cold soup in pure CSS (0 lines of JS — even the interaction)</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sat, 01 Aug 2026 16:40:27 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/okroshka-ice-cold-i-painted-a-khokhloma-bowl-of-cold-soup-in-pure-css-0-lines-of-js-even-the-j2m</link>
      <guid>https://dev.to/uptimearchitect/okroshka-ice-cold-i-painted-a-khokhloma-bowl-of-cold-soup-in-pure-css-0-lines-of-js-even-the-j2m</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge - Comfort Food Edition, CSS Art&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspiration
&lt;/h2&gt;

&lt;p&gt;Okroshka is the soup you eat when it's too hot to eat soup — a cold kvass (or&lt;br&gt;
kefir) base over a confetti of diced cucumber, radish, potato and egg, buried&lt;br&gt;
under more fresh dill than seems reasonable, with one proud dollop of smetana&lt;br&gt;
in the middle.&lt;/p&gt;

&lt;p&gt;Mine comes with a memory. When I was six, my parents took me haymaking in&lt;br&gt;
the water meadows. At noon we would hide in the dense shade of a fresh&lt;br&gt;
haystack, and my father would pull a jar wrapped in a thick towel out of a&lt;br&gt;
canvas bag: ice-cold okroshka my mother had made back at dawn. The cucumbers&lt;br&gt;
crunched, the sharp homemade kvass stung the tongue, and every sip drove the&lt;br&gt;
exhaustion away. (The full memory lives in my sister entry, linked below.)&lt;/p&gt;

&lt;p&gt;The scene I painted is the okroshka of my imagination's summer table:&lt;br&gt;
a &lt;strong&gt;Khokhloma bowl&lt;/strong&gt; — that black-lacquered Russian wooden ware painted with&lt;br&gt;
gold leaves and red berries — on a red lacquer saucer, over a green gingham&lt;br&gt;
tablecloth, with rye bread, a painted wooden spoon, and a little bowl of&lt;br&gt;
smetana standing by.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;iframe height="600" src="https://codepen.io/pyaro/embed/yygGVMg?height=600&amp;amp;default-tab=result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Click (or Tab + Space) the bowl to stir the smetana into the soup.&lt;/strong&gt; The&lt;br&gt;
cream sweeps a full turn and a half through the dill, the herb blanket gets&lt;br&gt;
dragged along with it, and it all settles back slowly when you let go.&lt;/p&gt;

&lt;p&gt;There is &lt;strong&gt;no JavaScript anywhere on this page&lt;/strong&gt; — not for the art, and not&lt;br&gt;
for the interaction. The stir is a visually-hidden (but fully keyboard-real)&lt;br&gt;
checkbox, a &lt;code&gt;:has()&lt;/code&gt; selector, and one registered &lt;code&gt;@property&lt;/code&gt; transition.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffq2g57nfo43x90mpahs3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffq2g57nfo43x90mpahs3.png" alt="The scene at rest: a Khokhloma bowl of okroshka under a blanket of dill on a green gingham cloth" width="800" height="557"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flsxghg4qu7rroshxetpe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flsxghg4qu7rroshxetpe.png" alt="Mid-stir: the white cream comma sweeping through the dill blanket" width="800" height="557"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;Some of the tricks I'm proudest of, in the order they saved me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The curved rim bands ("ellipse shingles").&lt;/strong&gt; A Khokhloma bowl has a red lip&lt;br&gt;
and a gold band that &lt;em&gt;curve&lt;/em&gt; with the rim. Horizontal gradient stripes can't&lt;br&gt;
bend — so each band is the same ellipse as the bowl opening, dropped a couple&lt;br&gt;
of &lt;code&gt;cqi&lt;/code&gt; lower. Only its curved front crescent peeks out from behind the&lt;br&gt;
ellipse above it. Three stacked ellipses, a perfectly curved rim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dill blanket.&lt;/strong&gt; The photo-real signature of okroshka is a surface&lt;br&gt;
&lt;em&gt;buried&lt;/em&gt; in chopped dill. Mine is five overlapping speckle patterns with&lt;br&gt;
deliberately co-prime-ish &lt;code&gt;background-size&lt;/code&gt; tiles (2.3×1.7, 1.7×1.3,&lt;br&gt;
2.9×2.1…) so the grids never visually align, a tiny &lt;code&gt;blur()&lt;/code&gt; to melt them&lt;br&gt;
into herb texture, and a radial ring mask that opens the center for the&lt;br&gt;
smetana dollop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stirring a flat ellipse.&lt;/strong&gt; Rotating the dill layer in screen space swings&lt;br&gt;
it out of the soup plane like a slab (I know because I shipped it for five&lt;br&gt;
minutes). The fix is the old animator's trick — squash → rotate → unsquash:&lt;br&gt;
&lt;code&gt;transform: scaleY(0.25) rotate(38deg) scaleY(4)&lt;/code&gt; stretches the ellipse into&lt;br&gt;
a circle, rotates it &lt;em&gt;in that space&lt;/em&gt;, and squashes it back. The silhouette&lt;br&gt;
never moves; the texture swirls in the plane of the soup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The smetana swirl.&lt;/strong&gt; A conic-gradient comma masked into a ring, whose start&lt;br&gt;
angle is a registered custom property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@property&lt;/span&gt; &lt;span class="n"&gt;--swirl&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;syntax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;'&amp;lt;angle&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;inherits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;initial-value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.stage&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.stir&lt;/span&gt;&lt;span class="nd"&gt;:checked&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;.soup&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--swirl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;540deg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.soup&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;--swirl&lt;/span&gt; &lt;span class="m"&gt;1.6s&lt;/span&gt; &lt;span class="n"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registration is the whole trick — an unregistered custom property would snap&lt;br&gt;
instead of sweeping. Unchecking transitions back over 2.4s, because cream&lt;br&gt;
settles slower than it stirs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dill fronds with &lt;code&gt;mask-composite&lt;/code&gt;.&lt;/strong&gt; A frond is a repeating-conic fan of&lt;br&gt;
needle rays &lt;em&gt;intersected&lt;/em&gt; with a 96° wedge — without the wedge the fan is a&lt;br&gt;
360° starburst (also shipped that for five minutes).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessibility, because art is still a page.&lt;/strong&gt; The scene is one&lt;br&gt;
&lt;code&gt;role="img"&lt;/code&gt; figure with alt text written like a photo caption. The checkbox&lt;br&gt;
and its label live &lt;em&gt;outside&lt;/em&gt; the figure (&lt;code&gt;role="img"&lt;/code&gt; flattens its subtree&lt;br&gt;
for assistive tech), the label announces "Stir the sour cream into the soup",&lt;br&gt;
and focus draws a gold ring around the bowl's lip. Every animation and&lt;br&gt;
transition lives inside &lt;code&gt;@media (prefers-reduced-motion: no-preference)&lt;/code&gt; —&lt;br&gt;
reduced-motion visitors get a finished still life, and the stir still works&lt;br&gt;
as an instant state change.&lt;/p&gt;

&lt;p&gt;Everything is sized in &lt;code&gt;cqi&lt;/code&gt; container units, so the same fragment drops&lt;br&gt;
into any page at any width — which is exactly what my sister entry does:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://dev.to/uptimearchitect/dear-okroshka-a-love-letter-to-a-cold-soup-and-a-landing-page-that-switches-sides-with-zero-ope"&gt;Dear Okroshka — a love letter to a cold soup&lt;/a&gt;&lt;/strong&gt;, a full landing page for the same dish.&lt;/p&gt;

&lt;p&gt;Code is MIT-licensed.&lt;/p&gt;

</description>
      <category>frontendchallenge</category>
      <category>devchallenge</category>
      <category>css</category>
    </item>
    <item>
      <title>Dear Okroshka: a love letter to a cold soup and a landing page that switches sides with zero JavaScript</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sat, 01 Aug 2026 16:36:13 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/dear-okroshka-a-love-letter-to-a-cold-soup-and-a-landing-page-that-switches-sides-with-zero-ope</link>
      <guid>https://dev.to/uptimearchitect/dear-okroshka-a-love-letter-to-a-cold-soup-and-a-landing-page-that-switches-sides-with-zero-ope</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge - Comfort Food Edition, Perfect Landing&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;The prompt allowed "a love letter to a regional dish," so I took it&lt;br&gt;
literally: &lt;strong&gt;Dear Okroshka&lt;/strong&gt; is a single-page letter written &lt;em&gt;to&lt;/em&gt; the cold&lt;br&gt;
Eastern European summer soup — with a salutation, enclosed evidence (it's on&lt;br&gt;
an actual Russian postage stamp), an anatomy of the bowl, a recipe you can&lt;br&gt;
check off while cooking, and a sign-off.&lt;/p&gt;

&lt;p&gt;Okroshka is my dish because of one memory: the shade of a fresh haystack at&lt;br&gt;
noon during haymaking, and a jar wrapped in a thick towel — ice-cold&lt;br&gt;
okroshka my mother had made at dawn, sharp kvass stinging the tongue, the&lt;br&gt;
air full of dill. That memory, nearly in full, is the letter's centerpiece&lt;br&gt;
on the page.&lt;/p&gt;

&lt;p&gt;The centerpiece is the argument every okroshka household has: &lt;strong&gt;kvass or&lt;br&gt;
kefir?&lt;/strong&gt; The page doesn't just describe the feud — it takes sides. A native&lt;br&gt;
radio group re-themes the entire page (colors, content strings, even the&lt;br&gt;
recipe's first ingredient) between an amber rye world and a cool milky one.&lt;/p&gt;

&lt;p&gt;The whole switch is CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;#base-kefir&lt;/span&gt;&lt;span class="nd"&gt;:checked&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#F5F8F2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;--ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#25322B&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;--accent-strong&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#2C5A3A&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* …every token */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;#base-kefir&lt;/span&gt;&lt;span class="nd"&gt;:checked&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;.only-kvass&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;JavaScript is optional on this page.&lt;/strong&gt; The ~60 lines it ships add two&lt;br&gt;
enhancements: scroll-reveal (gated on &lt;em&gt;both&lt;/em&gt; an &lt;code&gt;html.js&lt;/code&gt; class and&lt;br&gt;
&lt;code&gt;prefers-reduced-motion: no-preference&lt;/code&gt;, so no-JS and reduced-motion visitors&lt;br&gt;
see everything with zero flash) and a polite &lt;code&gt;role="status"&lt;/code&gt; announcement&lt;br&gt;
when the base changes. Turn JS off and the page still re-themes, the&lt;br&gt;
ingredient cards still open, the recipe still checks off.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;iframe height="600" src="https://codepen.io/pyaro/embed/EaZGNXQ?height=600&amp;amp;default-tab=result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkk3b29jtr9vmeijvgyba.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkk3b29jtr9vmeijvgyba.png" alt="The debate section in the kefir theme — pick a side and the whole page takes it" width="800" height="655"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why the switch is a radio group, not a toggle.&lt;/strong&gt; My first sketch was an&lt;br&gt;
&lt;code&gt;aria-pressed&lt;/code&gt; button. But neither kvass nor kefir is "off" — a switch is&lt;br&gt;
the wrong mental model, and a screen reader would announce it misleadingly.&lt;br&gt;
A native radio group in a fieldset gives exclusivity, arrow-key behavior and&lt;br&gt;
"selected, 1 of 2" announcements for free — and it's what makes the zero-JS&lt;br&gt;
theming possible at all. The accessible choice and the clever choice turned&lt;br&gt;
out to be the same choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessibility was a judging criterion, so I treated it as a feature:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Landmarks + labeled sections, one &lt;code&gt;h1&lt;/code&gt;, no heading skips; skip link.&lt;/li&gt;
&lt;li&gt;Both themes' text tokens are contrast-verified: every body-text pair
≥ 4.5:1 (kvass ink 11.66:1, muted 5.97:1; kefir ink 12.48:1, muted 5.87:1).&lt;/li&gt;
&lt;li&gt;The ingredient explorer is native &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; — keyboard and screen-reader
operable with zero ARIA bolted on. The recipe steps are real labeled
checkboxes; the done-state is strikethrough + the checkbox itself, never
color alone.&lt;/li&gt;
&lt;li&gt;Reduced motion: no reveal offsets, no smooth scroll, no theme transition.&lt;/li&gt;
&lt;li&gt;Forced colors (Windows High Contrast): the selected base keeps a
double-border so it survives color removal.&lt;/li&gt;
&lt;li&gt;Lighthouse accessibility: &lt;strong&gt;100&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Real photography, licensed properly.&lt;/strong&gt; The food photos are from Wikimedia&lt;br&gt;
Commons (CC BY / CC BY-SA / public domain), credited inline under each&lt;br&gt;
figure and in the footer — including a matched pair of kvass and kefir cups&lt;br&gt;
by the same photographer for the debate, a 2016 Russian okroshka postage&lt;br&gt;
stamp, and a July 1977 photo of a street kvass barrel with its queue.&lt;br&gt;
Choosing photos that share warmth turned out&lt;br&gt;
to be half the design system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No webfonts, no frameworks, no build.&lt;/strong&gt; The type system is two stacks:&lt;br&gt;
a Palatino-class serif for the letter's voice, system sans for the UI layer.&lt;br&gt;
The contrast between them &lt;em&gt;is&lt;/em&gt; the typography. One fluid clamp() scale, one&lt;br&gt;
token sheet, five labeled CSS layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sister entry.&lt;/strong&gt; The gingham ribbon under the hero is a hand-off: the&lt;br&gt;
same table setting exists as a pure-CSS painting — &lt;strong&gt;&lt;a href="https://dev.to/uptimearchitect/okroshka-ice-cold-i-painted-a-khokhloma-bowl-of-cold-soup-in-pure-css-0-lines-of-js-even-the-j2m"&gt;Okroshka, Ice-Cold&lt;/a&gt;&lt;/strong&gt; — my entry for the CSS Art prompt.&lt;br&gt;
Same dish, two languages.&lt;/p&gt;

&lt;p&gt;Code is MIT-licensed.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>frontendchallenge</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The transaction that committed in the past: how I diagnosed one bug six times in an evening</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sat, 01 Aug 2026 13:26:17 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/the-transaction-that-committed-in-the-past-how-i-diagnosed-one-bug-six-times-in-an-evening-gli</link>
      <guid>https://dev.to/uptimearchitect/the-transaction-that-committed-in-the-past-how-i-diagnosed-one-bug-six-times-in-an-evening-gli</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is a special kind of bug that doesn't crash anything. No exception, no error log, no red dashboard. The system hums along, green across the board — and quietly throws away your data.&lt;/p&gt;

&lt;p&gt;This is the story of one of those, in a change-data-capture engine I build in Rust — a system that reads Oracle's redo logs byte-by-byte, off the database, and replicates committed transactions to warehouses and streams. Its one sacred promise: &lt;strong&gt;if Oracle committed it, we deliver it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One June evening, a test proved the promise was a lie. Over the next three hours and thirteen minutes I diagnosed the bug six times. Only the first was right — and I dismissed it, then chased four wrong theories, caught myself in an embarrassing hex-arithmetic error halfway through, and circled all the way back to where I'd started. The trail of wrong turns is preserved, in real time, in my commit history, and I'm going to walk you through all of it, timestamps and all, because the wrong turns &lt;em&gt;are&lt;/em&gt; the story.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Up front, so you know what you're reading: the engine is closed-source, so there's no repo to hand you. But every commit message I quote below is real — lifted verbatim from that evening's git history, wrong arithmetic and all. Internal tool and ticket names are the only things I've changed.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Two weeks earlier I had shipped the engine's real-time mode: instead of waiting for Oracle to archive a redo log and reading it cold, the tailer polls the &lt;strong&gt;live online redo log&lt;/strong&gt; every few hundred milliseconds — decoding a file &lt;em&gt;while Oracle is still writing it&lt;/em&gt;. Median commit-to-decoded latency: 238 ms. I was very proud of it.&lt;/p&gt;

&lt;p&gt;Polling a live file means re-decoding an ever-growing window, which means seeing the same transactions again on every poll. So the tailer deduplicated across polls the obvious way: remember the highest commit SCN you've emitted (Oracle's System Change Number — a monotonically increasing clock stamped on every change), and on the next poll, skip everything at or below that floor.&lt;/p&gt;

&lt;p&gt;Simple. Obvious. Wrong in a way that took me six theories — and a full circle back to the first — to prove.&lt;/p&gt;

&lt;h2&gt;
  
  
  20:03 — Theory #1: it's the dedup floor
&lt;/h2&gt;

&lt;p&gt;A long-transaction test went red: open a transaction, leave it open while a dozen short transactions commit, then commit it. The engine captured the twelve short ones and &lt;strong&gt;silently dropped the long one&lt;/strong&gt;. The archived-log path captured it fine — this was real-time-mode only.&lt;/p&gt;

&lt;p&gt;My first commit that evening documented a suspicion that will sound very smart in about three hours:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"the windowed re-decode dedups by a scalar commit-SCN floor … the txn's insert is released under that prior (low) commit SCN, and once the floor passes it the row is pruned forever. … **Proper fix is a design change: dedup by transaction identity … not a scalar SCN floor.&lt;/em&gt;&lt;em&gt;"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wrote down the correct fix, in the first twenty minutes, in the commit message. Then I spent the rest of the evening talking myself out of it — because my repro was dirty in a way I didn't yet understand, and clean-looking evidence was about to point somewhere much scarier.&lt;/p&gt;

&lt;h2&gt;
  
  
  20:17 — Theory #2: no, it's the core decoder
&lt;/h2&gt;

&lt;p&gt;I tightened the repro with unique per-run markers, re-ran, and the new evidence said the dropped row's &lt;code&gt;commit_scn&lt;/code&gt; equaled its &lt;em&gt;insert&lt;/em&gt; SCN — not its real commit SCN. Worse: the batch path decoding the very same log showed the same wrong value. This wasn't a real-time dedup quirk. This was the &lt;strong&gt;core decoder&lt;/strong&gt; stamping the wrong commit SCN on any transaction held open while others commit.&lt;/p&gt;

&lt;p&gt;I escalated it in the log: core decode bug, affects every path, do not rush the fix. The dedup-floor theory from 14 minutes ago? Officially a red herring, "misled by cross-run contamination." (Hold that phrase.)&lt;/p&gt;

&lt;h2&gt;
  
  
  20:27 — Theory #3: a single wrong byte
&lt;/h2&gt;

&lt;p&gt;If the decoder stamps the wrong SCN, find where. I dumped the raw redo with &lt;code&gt;ALTER SYSTEM DUMP LOGFILE&lt;/code&gt; and went byte-level. (A held-open transaction is an &lt;em&gt;IMU&lt;/em&gt; — in-memory undo — transaction: Oracle buffers its redo privately and flushes begin + insert + commit as one unit at commit time.) The dump was unambiguous: the row's true SCN was &lt;code&gt;0x150b4a1&lt;/code&gt; everywhere it mattered, but the decoder emitted &lt;code&gt;0x150b2a1&lt;/code&gt; — a value that appears &lt;strong&gt;nowhere&lt;/strong&gt; in the log. One byte off. My commit concluded the parser was mis-framing the record, and helpfully wrote out the decimal conversions of both hex values.&lt;/p&gt;

&lt;p&gt;One of those conversions was wrong. Nobody noticed. Especially not me.&lt;/p&gt;

&lt;h2&gt;
  
  
  20:31 — Theory #4: the byte is fabricated downstream
&lt;/h2&gt;

&lt;p&gt;Four minutes later, an offline probe over the preserved log killed Theory #3: the parser framed the record perfectly. Every change-vector SCN in that record was correct. The mystery value &lt;code&gt;0x150b2a1&lt;/code&gt; matched &lt;em&gt;no&lt;/em&gt; header anywhere in the file — so it wasn't mis-read, it was &lt;strong&gt;fabricated&lt;/strong&gt; somewhere downstream in event assembly. I listed three candidate functions and narrowed the hunt.&lt;/p&gt;

&lt;p&gt;I was now hunting the origin of a value that did not exist. For a very good reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  20:42 — Theory #5: the pivot
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"That was a HEX-CONVERSION ERROR on my part: &lt;code&gt;0x150b4a1&lt;/code&gt; = 22066337 (the CORRECT commit SCN), not &lt;code&gt;0x150b2a1&lt;/code&gt;. The held-open IMU transaction actually decodes CORRECTLY."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There it is, verbatim, the most embarrassing commit message I have ever written — and the most valuable. Fifteen minutes earlier, doing arithmetic in my head at 20:27, I had converted &lt;code&gt;0x150b4a1&lt;/code&gt; to the wrong decimal and then pinned the &lt;em&gt;correct&lt;/em&gt; decimal onto the &lt;em&gt;wrong&lt;/em&gt; hex string. The decoder had been right all along. The "single wrong byte" was in my head. The batch path was fine; the caveats I'd splashed across three docs got withdrawn.&lt;/p&gt;

&lt;p&gt;Which meant the bug was real-time-only after all… and I still hadn't found it. Two reasoned fixes had already failed — implemented, 140 tests green, repro still red, reverted. My commit ends with a change of method that decided the whole hunt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"the next step is an OFFLINE poll-simulation over the preserved corpus to pin the exact drop and iterate the fix fast (not rebuild-and-pray)."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  21:52 — Theory #6: the ghost with four timestamps
&lt;/h2&gt;

&lt;p&gt;I spent the next seventy minutes building instead of guessing: I split the tailer's dedup core into a pure function an offline test could drive, and built a serializer that dumps a &lt;em&gt;live mid-write window&lt;/em&gt; — the exact bytes the tailer saw mid-poll — to a file that replays deterministically offline.&lt;/p&gt;

&lt;p&gt;The offline simulation immediately proved the sealed decode and the dedup path were both correct on any static snapshot. And the live trace showed something spectacular: the same &lt;code&gt;id=1000&lt;/code&gt; row appearing poll after poll with a &lt;strong&gt;different, increasing commit SCN each time&lt;/strong&gt; — &lt;code&gt;22087937 → 22089678 → 22090175 → 22090911&lt;/code&gt;. One row, four commit timestamps. A transaction whose commit kept moving.&lt;/p&gt;

&lt;p&gt;New theory, stated with great confidence: when a held-open transaction's own commit record is still beyond the write head, the assembler mis-attributes its commit SCN to the latest visible commit. The row is right, the metadata is wrong, the floor prunes it. Q.E.D.&lt;/p&gt;

&lt;p&gt;It's a beautiful theory. It survived exactly eighty-four minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  23:16 — What the NOTE column said
&lt;/h2&gt;

&lt;p&gt;The test table had a &lt;code&gt;NOTE&lt;/code&gt; column I'd been ignoring — each test run writes a unique random marker into it. I finally dumped it for those four "re-attributed" rows:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;r1854822674_long&lt;/code&gt;, &lt;code&gt;r878629041_long&lt;/code&gt;, &lt;code&gt;r348217387_long&lt;/code&gt;, &lt;code&gt;X3647_long&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Four &lt;em&gt;different&lt;/em&gt; markers. Four different &lt;strong&gt;runs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Oracle's online redo logs are circular — a "new" log is a recycled one, still holding valid-looking blocks from previous incarnations. Every run of my test did &lt;code&gt;DROP TABLE … PURGE&lt;/code&gt;, &lt;code&gt;CREATE&lt;/code&gt;, insert &lt;code&gt;id=1000&lt;/code&gt;. So four runs' worth of stale &lt;code&gt;id=1000&lt;/code&gt; rows were sitting in the log, landed in one decode window, and read as "one row whose commit SCN keeps changing." There was no re-attribution. There was no moving commit. &lt;strong&gt;The centerpiece evidence of Theory #6 was four different transactions wearing the same primary key.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And with the ghosts exorcised, the live trace of the &lt;em&gt;current&lt;/em&gt; run finally showed the actual smoking gun, small and undramatic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;held-open txn:  commit_scn = 22103071   ← correct, its own, real
dedup floor:                 22103185   ← already past it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A transaction held open while others commit gets its commit record written &lt;em&gt;after&lt;/em&gt; theirs — so it becomes &lt;strong&gt;readable last with an SCN from the past&lt;/strong&gt;. It commits earlier in SCN time, later in file time. By the time its commit surfaced in the window, the scalar floor — advanced by the younger transactions that beat it into the file — had already rolled past it, and the dedup pruned it as "already seen."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5rqkiefowh8beezt2yax.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5rqkiefowh8beezt2yax.png" alt="The same four commits in two orders: by SCN, the held-open transaction has the lowest number (committed first); by read order it arrives last, after the dedup floor has already climbed past it to 22103185, so its commit at 22103071 is pruned as already-seen" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Theory #1. From 20:03. The one whose correct fix I wrote into my first commit message and then abandoned because a dirty repro made better theories look true. The bug was never in the decode, never in the framing, never a wrong byte, never a mis-attribution. It was the floor, all along, exactly as first suspected — only now &lt;em&gt;proven&lt;/em&gt;, with a trace that distinguished this run's transaction from its four dead ancestors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Dedup by &lt;strong&gt;transaction identity&lt;/strong&gt;, not by a scalar SCN watermark. The tailer now tracks &lt;code&gt;emitted: HashMap&amp;lt;TxId, commit_scn&amp;gt;&lt;/code&gt;, self-bounded each poll to transactions still decodable in the window (anything that aged out of the circular log can never reappear, so it needn't be remembered). A transaction is emitted iff its commit &lt;code&gt;TxId&lt;/code&gt; hasn't been — so an out-of-SCN-order held-open commit can never be pruned by younger neighbors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nn"&gt;CaptureMessage&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Commit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tx_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;already&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;emitted_txns&lt;/span&gt;&lt;span class="nf"&gt;.contains_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// identity, not an SCN floor&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;already&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;emitted_txns&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_id&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;scn&lt;/span&gt;&lt;span class="na"&gt;.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="nf"&gt;.append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="nf"&gt;.clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The regression test encodes the whole evening in fourteen lines: poll 1 emits a transaction committed at SCN 200; poll 2 surfaces a held-open commit at SCN &lt;strong&gt;150&lt;/strong&gt; — &lt;em&gt;below&lt;/em&gt; the highest already emitted — and asserts it &lt;strong&gt;must&lt;/strong&gt; still come out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;out2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select_new_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;poll2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;txns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;ddl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nd"&gt;assert_eq!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;commit_scns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;out2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s"&gt;"held-open commit (150) emitted though it is below the already-emitted 200"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The long-transaction gate flipped from KNOWN-FAIL to a mandatory passing gate: 2/2, three consecutive runs, on a live containerized Oracle 23ai. 145 unit tests green, p95 poll latency 248 ms — the fix costs nothing measurable.&lt;/p&gt;

&lt;p&gt;And the offline window-replay tool, built at 21:52 to chase a bug that turned out not to exist? Its &lt;em&gt;inability&lt;/em&gt; to reproduce the drop on any static snapshot was the strongest clue that the decode was innocent. Sometimes the most useful thing a tool can tell you is &lt;em&gt;"not here."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jktallua1xbe6qi3z6g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jktallua1xbe6qi3z6g.png" alt="Timeline of the evening: theory #1 at 20:03 (right, but dismissed), four wrong theories and one hex-error pivot in between, and at 23:16 the fix — which was theory #1 all along, with a return arrow looping back to the start" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I keep from that evening
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The log remembers your old experiments.&lt;/strong&gt; Circular buffers, recycled logs, reused primary keys — any repro that can't distinguish &lt;em&gt;this run&lt;/em&gt; from &lt;em&gt;previous runs&lt;/em&gt; will eventually hand you fiction. One unique marker per run turned three hours of ghost-hunting into one &lt;code&gt;SELECT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give your first theory the same rigor as your last.&lt;/strong&gt; I wrote the correct diagnosis &lt;em&gt;and the correct fix&lt;/em&gt; in the first commit — then abandoned them because contaminated evidence made a scarier theory look stronger. Theories aren't refuted by newer theories; they're refuted by clean evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When rebuild-and-pray fails twice, stop and build a replay harness.&lt;/strong&gt; The serializer + offline simulator took one focused hour and ended a hunt that guessing would have extended indefinitely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the wrong turns down.&lt;/strong&gt; Every one of these quotes is a real commit message from that evening, wrong arithmetic and all. That paper trail is why I can tell you this story — and why the post-mortem took ten minutes instead of a week.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The transaction that committed in the past ships on time now. Its commit SCN is lower than its neighbors'; its identity is its own; and there's a HashMap that will never forget it — for exactly one window, and not a poll longer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the longest you've chased a bug that turned out to be your test harness lying to you? I want to hear the war story in the comments.&lt;/strong&gt; 👇&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is one of my two Summer Bug Smash entries. The other is a Clear the Lineup fix — &lt;a href="https://dev.to/uptimearchitect/zulip-logged-successfully-processed-your-reply-was-already-gone-4727"&gt;a year-old Zulip bug that six contributors tried before me&lt;/a&gt; — where the hard part wasn't the code, it was finishing what everyone else started.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written up from that evening's git history (2026-06-18, 20:03–23:16). The engine is a from-scratch Rust CDC system that decodes Oracle redo logs off-database; this real-time path was already live in production, which is exactly what made a silent, deniable transaction drop worth three hours of hunting.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>debugging</category>
      <category>rust</category>
    </item>
    <item>
      <title>Zulip logged 'Successfully processed.' Your reply was already gone.</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sat, 01 Aug 2026 13:22:01 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/zulip-logged-successfully-processed-your-reply-was-already-gone-4727</link>
      <guid>https://dev.to/uptimearchitect/zulip-logged-successfully-processed-your-reply-was-already-gone-4727</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You reply to an email notification from your team chat — a quick "sounds good" to a thread you missed. It never arrives. No bounce, no error, no red anything. And the server's own logs report, cheerfully, &lt;em&gt;"Successfully processed email."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That reply had already been thrown away. And it kept happening for a full year — in a tool thousands of organizations trust — while six people tried and failed to fix it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Replying by email to a group-DM notification silently dropped your message whenever a group member had been deactivated (while logging success). The fix is ~15 lines; the PR is &lt;a href="https://github.com/zulip/zulip/pull/39766" rel="noopener noreferrer"&gt;#39766&lt;/a&gt;, now &lt;strong&gt;merged&lt;/strong&gt; into Zulip's &lt;code&gt;main&lt;/code&gt;. Sentry's Seer independently reconstructed my diagnosis from one captured event, and Gemini stress-tested my test coverage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;The feature at the center of this is &lt;a href="https://github.com/zulip/zulip" rel="noopener noreferrer"&gt;Zulip&lt;/a&gt;'s &lt;strong&gt;email gateway&lt;/strong&gt; — Zulip being the open-source team chat thousands of organizations run themselves. Miss a message, get a notification email, and just &lt;em&gt;reply to it&lt;/em&gt;: your reply is parsed and dropped straight back into the conversation. Brilliant — until the conversation is a group DM and one of its members has since been deactivated. Then your reply vanishes, and the log lies about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/zulip/zulip/issues/35273" rel="noopener noreferrer"&gt;Issue #35273&lt;/a&gt;&lt;/strong&gt;, opened by a Zulip core maintainer in July 2025 — one year old this week. When someone replies by email to a group-DM notification and any member of that group has been deactivated, message validation throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;django&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;deactivated-recipient@example.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; is no longer using Zulip.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;zerver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonableError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deactivated-recipient@example.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;longer&lt;/span&gt; &lt;span class="n"&gt;using&lt;/span&gt; &lt;span class="n"&gt;Zulip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The exception is caught deep in the internal send path and logged — so nothing crashes, nothing retries, and the sender is never told. The reply is simply &lt;strong&gt;gone&lt;/strong&gt;. And here's the lie from the intro — my favorite detail of the whole bug. Right after eating your message, the mirror logs&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO Successfully processed email from user 8 to ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A success log for a message that was never delivered. That's the worst class of bug there is: &lt;strong&gt;silent data loss that every party — sender, server, and logs — believes succeeded.&lt;/strong&gt; Nobody files a report for a message they don't know they lost, which is exactly why it hid in a shipping product for a year.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn7x0appkegfj19lfz2iz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn7x0appkegfj19lfz2iz.png" alt="Before/after: the unfixed path builds a recipient list including the deactivated user, validation rejects it, the JsonableError is swallowed, " width="800" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The web app can't hit this — it refuses to compose to a group containing a deactivated user at all. Only the email path, replying to a notification that &lt;em&gt;predates&lt;/em&gt; the deactivation, can walk into it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bug, by the numbers:&lt;/strong&gt; open &lt;strong&gt;1 year&lt;/strong&gt; · &lt;strong&gt;6&lt;/strong&gt; contributors claimed it and drifted off · &lt;strong&gt;4&lt;/strong&gt; PRs opened, &lt;strong&gt;0&lt;/strong&gt; merged · the fix is &lt;strong&gt;~15 lines of logic&lt;/strong&gt; + &lt;strong&gt;3 new tests&lt;/strong&gt; · &lt;strong&gt;75/75&lt;/strong&gt; email-mirror tests green · CI green on &lt;strong&gt;5&lt;/strong&gt; Python versions (3.10–3.14) · &lt;strong&gt;now merged to &lt;code&gt;main&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Behind those numbers is a pattern.&lt;/strong&gt; Every one of the six claimants was auto-unassigned for inactivity; none of the four PRs cleared review. The most promising got a "Looks great!" from a maintainer with a short punch-list — use &lt;code&gt;.values_list("email", flat=True)&lt;/code&gt;, add a test where the conversation &lt;em&gt;remains&lt;/em&gt; a group DM, extract the duplicated test setup into a helper, and also handle the 1:1 DM case — and then stalled in commit-style churn. So before writing a line, I read all four attempts and that review, and treated the punch-list as my spec.&lt;/p&gt;

&lt;p&gt;The reading paid off with the key insight: &lt;strong&gt;the "1:1 case" no longer exists.&lt;/strong&gt; Since the earlier attempts, Zulip finished migrating &lt;em&gt;all&lt;/em&gt; direct messages — including 1:1s — to direct-message groups and dropped personal recipient rows entirely. The separate 1:1 fix the reviewer asked for in January is now structurally impossible &lt;em&gt;and&lt;/em&gt; structurally unnecessary: one fix in the group branch covers everything.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag_github-liquid-tag"&gt;
  &lt;h1&gt;
    &lt;a href="https://github.com/zulip/zulip/pull/39766" rel="noopener noreferrer"&gt;
      &lt;img class="github-logo" alt="GitHub logo" src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg"&gt;
      &lt;span class="issue-title"&gt;
        [ai] email_mirror: Ignore deactivated users in group DM email replies.
      &lt;/span&gt;
      &lt;span class="issue-number"&gt;#39766&lt;/span&gt;
    &lt;/a&gt;
  &lt;/h1&gt;
  &lt;div class="github-thread"&gt;
    &lt;div class="timeline-comment-header"&gt;
      &lt;a href="https://github.com/pyaroslav" rel="noopener noreferrer"&gt;
        &lt;img class="github-liquid-tag-img" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F20116942%3Fv%3D4" alt="pyaroslav avatar"&gt;
      &lt;/a&gt;
      &lt;div class="timeline-comment-header-text"&gt;
        &lt;strong&gt;
          &lt;a href="https://github.com/pyaroslav" rel="noopener noreferrer"&gt;pyaroslav&lt;/a&gt;
        &lt;/strong&gt; posted on &lt;a href="https://github.com/zulip/zulip/pull/39766" rel="noopener noreferrer"&gt;&lt;time&gt;Jul 19, 2026&lt;/time&gt;&lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div class="ltag-github-body"&gt;
      &lt;p&gt;Fixes: #35273&lt;/p&gt;
&lt;p&gt;Replying by email to a message notification email for a group direct message raised an unhandled &lt;code&gt;JsonableError&lt;/code&gt; when any member of the group had since been deactivated, and the reply was silently lost (while the mirror still logged "Successfully processed email").&lt;/p&gt;
&lt;p&gt;This filters the group's members to active users when constructing the recipient list, so the reply is delivered to the remaining active participants — consistent with the web app, which does not offer composing to such a group at all. If nobody but the sender remains active, the reply is dropped with an INFO log rather than an exception.&lt;/p&gt;
&lt;p&gt;Two notes for review:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Since personal &lt;code&gt;Recipient&lt;/code&gt; rows no longer exist (1:1 direct messages are direct message groups), the same branch covers the 1:1 case raised in the review of #37449; a separate commit for it is no longer meaningful.&lt;/li&gt;
&lt;li&gt;The filtering query adds one database query to the reply path, so the query-count assertions in the two existing tests are updated from 22 to 23.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A possible follow-up (not included, to keep this scoped like the previously reviewed approach): notifying the sender via the notification bot when the reply is dropped, as &lt;code&gt;send_mm_reply_to_stream&lt;/code&gt; does for channel replies.&lt;/p&gt;
&lt;p&gt;Prior work on this issue: #36882, #37449 (whose review feedback is addressed here: &lt;code&gt;values_list&lt;/code&gt;-based filtering, a test where the conversation remains a group DM, and the repeated test setup extracted into helpers), #38117, #38933.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How changes were tested:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] &lt;code&gt;./tools/test-backend zerver.tests.test_email_mirror.TestMissedMessageEmailMessages&lt;/code&gt; — 12/12 pass (re-run after rebasing on current main); the three new tests fail without the lib change (silent message loss, no drop log).&lt;/li&gt;
&lt;li&gt;[x] &lt;code&gt;./tools/lint zerver/lib/email_mirror.py zerver/tests/test_email_mirror.py&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[x] Coverage: &lt;code&gt;zerver/lib/email_mirror.py&lt;/code&gt; shows no uncovered lines under this test class.&lt;/li&gt;
&lt;li&gt;[x] Reproduced end-to-end in the dev environment (group DM → deactivate a member → email reply): before the fix the reply is lost; after, it is delivered to the remaining active members.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Screenshots and screen captures:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;N/A (backend-only change).&lt;/p&gt;

Self-review checklist
&lt;ul&gt;
&lt;li&gt;[x] Self-reviewed the changes for clarity and maintainability
(variable names, code reuse, readability, etc.).&lt;/li&gt;
&lt;li&gt;[x] Followed the &lt;a href="https://zulip.readthedocs.io/en/latest/contributing/contributing.html#ai-use-policy-and-guidelines" rel="nofollow noopener noreferrer"&gt;AI use policy&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Communicate decisions, questions, and potential concerns.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] Explains differences from previous plans (e.g., issue description).&lt;/li&gt;
&lt;li&gt;[x] Highlights technical choices and bugs encountered.&lt;/li&gt;
&lt;li&gt;[x] Calls out remaining decisions and concerns.&lt;/li&gt;
&lt;li&gt;[x] Automated tests verify logic where appropriate.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Individual commits are ready for review (see &lt;a href="https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html" rel="nofollow noopener noreferrer"&gt;commit discipline&lt;/a&gt;).&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] Each commit is a coherent idea.&lt;/li&gt;
&lt;li&gt;[x] Commit message(s) explain reasoning and motivation for changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Completed manual review and testing of the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Visual appearance of the changes.&lt;/li&gt;
&lt;li&gt;[ ] Responsiveness and internationalization.&lt;/li&gt;
&lt;li&gt;[ ] Strings and tooltips.&lt;/li&gt;
&lt;li&gt;[x] End-to-end functionality of the changes.&lt;/li&gt;
&lt;li&gt;[ ] Accessibility.&lt;/li&gt;
&lt;li&gt;[ ] Theming.&lt;/li&gt;
&lt;/ul&gt;


    &lt;/div&gt;
    &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/zulip/zulip/pull/39766" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;The whole change is about two dozen lines in &lt;code&gt;zerver/lib/email_mirror.py&lt;/code&gt; (roughly half of them comments): drop the group's &lt;strong&gt;deactivated&lt;/strong&gt; members before constructing the recipient list, deliver to whoever remains, and if nobody but the sender is left, log and drop instead of throwing. Here's the logic, comments elided:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;Recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DIRECT_MESSAGE_GROUP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;display_recipient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_display_recipient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user_dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;user_dict&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;display_recipient&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;emails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;UserProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id__in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_mirror_dummy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flat&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dropping message notification email reply from user %s to a group direct message with no active recipients&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;user_profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;recipient_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;internal_send_group_direct_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;emails&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One subtlety in that query. The &lt;code&gt;Q(is_active=True) | Q(is_mirror_dummy=True)&lt;/code&gt; keeps &lt;em&gt;both&lt;/em&gt; active members and "mirror-dummy" users — inactive placeholder accounts that Zulip's own recipient validation still treats as valid recipients — while dropping genuinely deactivated members. A flat &lt;code&gt;is_active=True&lt;/code&gt; filter would silently drop the mirror-dummy users; a reviewer caught exactly that on an earlier revision, which is why the filter mirrors the validation's rule instead. (My first cut also carried an &lt;code&gt;.order_by("id")&lt;/code&gt; to pin the ordering; a reviewer pointed out it was unnecessary once the result only feeds a recipient set, so it's gone — the review made the code smaller, not just correct.)&lt;/p&gt;

&lt;p&gt;Plus three new backend tests (delivered-and-stays-a-group-DM, keeps-a-mirror-dummy-member, only-sender-remains-so-drop) and the test-helper refactor the reviewer asked for — extracted into its own preparatory commit, per Zulip's commit discipline. The filtering adds exactly one indexed query, so the two existing tests' query-count assertions moved from 22 to 23 — stated in the PR rather than hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;The discipline was: make the deactivation tests fail on &lt;code&gt;main&lt;/code&gt; for the &lt;em&gt;right reasons&lt;/em&gt; before making them pass (the third test guards the opposite direction — that a valid inactive "mirror-dummy" member is &lt;em&gt;kept&lt;/em&gt;, not over-filtered). On unfixed code they fail exactly as production does — recipients' latest message is still the &lt;em&gt;original&lt;/em&gt; group DM (the reply was eaten), and the drop-path test finds the lying "Successfully processed" log where the graceful drop should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;AssertionError: Actual and expected outputs do not match; showing diff.
&lt;/span&gt;&lt;span class="gd"&gt;- original group direct message
&lt;/span&gt;&lt;span class="gi"&gt;+ Reply with deactivated group member body
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the fix: the whole &lt;code&gt;test_email_mirror&lt;/code&gt; module is green (75/75, my three new tests included), &lt;code&gt;./tools/lint&lt;/code&gt; (including mypy) clean, and coverage shows &lt;strong&gt;zero uncovered lines&lt;/strong&gt; in &lt;code&gt;email_mirror.py&lt;/code&gt; under this suite — including the drop path.&lt;/p&gt;

&lt;p&gt;Design choice worth surfacing: when members are filtered out, the reply is delivered to the &lt;em&gt;reduced&lt;/em&gt; group (the active members' conversation), matching what the previously-reviewed attempt did and what the maintainers endorsed in-thread. And when nobody but the sender remains, we log-and-drop rather than crash; a follow-up could notify the sender via Zulip's notification bot, the way stream-reply failures already do — I flagged that in the PR rather than scope-creeping into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;Zulip ships Sentry integration natively (&lt;code&gt;zproject/sentry.py&lt;/code&gt;) — enabling it in the dev environment took exactly one environment variable, which made a live before/after demo of this bug almost embarrassingly easy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1q92ajlwwqz7ye7n2qs6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1q92ajlwwqz7ye7n2qs6.gif" alt="Live: firing the crash in a terminal, then the JsonableError surfacing in Sentry, the traceback pinning the exact except→raise line, and Seer reconstructing the same root cause" width="480" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole loop, start to finish; the detail on each step is below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; I ran the real scenario end-to-end — group DM, deactivate a member, reply by email through &lt;code&gt;process_message()&lt;/code&gt; — with the unfixed code and a Sentry DSN. The swallowed &lt;code&gt;JsonableError&lt;/code&gt; arrives in Sentry as a first-class error event with the full traceback from the issue:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgixwb0nmbw40g7tdjjv6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgixwb0nmbw40g7tdjjv6.png" alt="The captured event's stack trace, showing the chained ValidationError → JsonableError raised at message_send.py where the ValidationError is re-raised" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The trace pins it exactly: &lt;code&gt;except ValidationError as e: … raise JsonableError(e.messages[0])&lt;/code&gt; in &lt;code&gt;check_message&lt;/code&gt; — the reply is turned into an exception, caught upstream, logged, and dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seer:&lt;/strong&gt; I connected my Zulip fork and pointed Sentry's &lt;strong&gt;Seer&lt;/strong&gt; at the captured event. Its root-cause analysis independently landed on the same mechanism I'd fixed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faotr2uw1lcpxvwzh1y8g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faotr2uw1lcpxvwzh1y8g.png" alt="Seer's root-cause analysis: the email reply handler calls _internal_prep_message → check_message with allow_deactivated=False, the recipient list still includes the deactivated user, raising JsonableError and silently dropping the reply — plus reproduction steps" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"The recipient list for the conversation still includes the deactivated user11, causing a &lt;code&gt;JsonableError&lt;/code&gt; and silently dropping the email reply."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's exactly the bug — and Seer reconstructed the reproduction steps (create group DM → deactivate a member → reply by email) matching the ones I'd used, from a single captured event. A nice independent check on the diagnosis before shipping the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt; identical scenario, fixed code — the reply is delivered to the remaining active members, the success log now lists only the active recipients, no new event is captured, and I marked the issue resolved (you can watch the whole before→after in the recording above).&lt;/p&gt;

&lt;p&gt;What sold me on the workflow: this bug's signature — &lt;em&gt;error event + success log + missing message&lt;/em&gt; — is invisible in any one log file, but obvious the moment the exception becomes a first-class object in Sentry with grouping and history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Google AI
&lt;/h2&gt;

&lt;p&gt;Green tests are a trap: they prove the cases you &lt;em&gt;wrote&lt;/em&gt; pass, not the cases you forgot. So I handed &lt;strong&gt;Gemini 2.5 Flash&lt;/strong&gt; (via the Google AI Studio API) the exact patch and my three test descriptions and asked the one question I can't ask myself objectively — &lt;em&gt;what am I not testing?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It went straight for the boundary that worried me most, the &lt;code&gt;len(emails) &amp;lt;= 1&lt;/code&gt; drop path, and reasoned it out instead of hand-waving:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Sender is the only active member in a group DM&lt;/strong&gt; — &lt;em&gt;Correctly dropped and logged. &lt;code&gt;len(emails) &amp;lt;= 1&lt;/code&gt; correctly handles this, as the sender's email will be in &lt;code&gt;emails&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the load-bearing assumption of the whole drop branch — the sender is always in the list, so "nobody left" means length 1, not 0. Then it flagged the &lt;code&gt;email&lt;/code&gt; vs &lt;code&gt;delivery_email&lt;/code&gt; question under restricted email-visibility — the &lt;em&gt;exact&lt;/em&gt; trap I'd already hit building the repro, where the send path resolves recipients by &lt;code&gt;.email&lt;/code&gt;, not &lt;code&gt;.delivery_email&lt;/code&gt; — and correctly scoped it to the downstream function rather than crying wolf on my diff. It also named two recipient shapes my tests don't exercise (bot members, cross-realm members) and reasoned both already-correct, which is why I kept the PR scoped to the deactivation case instead of expanding it.&lt;/p&gt;

&lt;p&gt;No bug fell out — and on a year-old issue that had already beaten six people, that was the win. Gemini bought me the thing you skip when the suite is green: a fast, adversarial second read that turns &lt;em&gt;"my tests pass"&lt;/em&gt; into &lt;em&gt;"I know exactly which cases I'm not covering, and why they're safe."&lt;/em&gt; The full prompt and response are saved alongside the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Six people had the right instinct on this issue; what was missing wasn't cleverness, it was finishing the job — reading the one review that already said exactly what to do, and acting on all of it. If you're hunting for a first open-source contribution, the highest-leverage move is often to pick up the issue everyone &lt;em&gt;started&lt;/em&gt; and nobody &lt;em&gt;closed&lt;/em&gt;. The trail of dead PRs isn't a warning sign — it's a spec. I followed it, and PR #39766 is now merged into &lt;code&gt;main&lt;/code&gt; — the year-old issue closed with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Have you ever shipped (or inherited) a "Successfully processed" log line sitting right on top of a dropped message? Tell me the worst one in the comments.&lt;/strong&gt; 👇&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is one of my two Summer Bug Smash entries. The other is a Smash Stories deep-dive — &lt;a href="https://dev.to/uptimearchitect/the-transaction-that-committed-in-the-past-how-i-diagnosed-one-bug-six-times-in-an-evening-gli"&gt;the data-loss bug I diagnosed six times in one evening&lt;/a&gt; — different chaos, same lesson about trusting your evidence over your theories.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>OCI vs Oracle Database@Azure: Where Should Your Oracle Database Live?</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sun, 26 Jul 2026 20:36:40 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oci-vs-oracle-databaseazure-where-should-your-oracle-database-live-3190</link>
      <guid>https://dev.to/uptimearchitect/oci-vs-oracle-databaseazure-where-should-your-oracle-database-live-3190</guid>
      <description>&lt;p&gt;You've decided the database is going to the cloud, and you've &lt;a href="https://uptimearchitect.com/blog/oracle-cloud-migration-methods/" rel="noopener noreferrer"&gt;picked a migration method&lt;/a&gt;. Now comes the question that determines everything downstream — networking, contracts, operations, and what your 3am looks like: &lt;strong&gt;where does the database actually live?&lt;/strong&gt; For most Oracle estates in 2026 that's a choice between &lt;strong&gt;OCI native&lt;/strong&gt; and &lt;strong&gt;Oracle Database@Azure&lt;/strong&gt; — and a third option people keep forgetting, the &lt;strong&gt;OCI–Azure Interconnect&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The wrong way to decide is by database features, because that's the trick of it: &lt;strong&gt;the database is the same in all three&lt;/strong&gt;. Same Exadata, same 19c/26ai, same Data Guard, same RMAN. What changes is everything &lt;em&gt;around&lt;/em&gt; the database — and that's where the decision really lives.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short version.&lt;/strong&gt; &lt;strong&gt;Database@Azure&lt;/strong&gt; puts Oracle-operated Exadata hardware &lt;em&gt;physically inside Azure datacenters&lt;/em&gt;: you provision from the Azure portal, latency to your Azure apps is same-building, and the spend burns down your &lt;strong&gt;Azure commitment (MACC)&lt;/strong&gt;. &lt;strong&gt;OCI native&lt;/strong&gt; gives you the full Oracle cloud — every service, typically the lowest total cost — and is the obvious home when your apps aren't married to Azure. The &lt;strong&gt;Interconnect&lt;/strong&gt; is the middle path: database in OCI, apps in Azure, &lt;strong&gt;under 2ms&lt;/strong&gt; between them with no egress charges over the link. Decide by three things: &lt;strong&gt;where your apps run, who holds your commercial commitments, and how much latency the app-to-database path can afford.&lt;/strong&gt; And the same template now works for AWS and Google Cloud — Database@AWS and Database@Google Cloud are the identical play on different hosts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Three options, not two
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. OCI native.&lt;/strong&gt; The database runs in Oracle's cloud — Exadata Database Service, Autonomous Database, Base Database, or plain compute — alongside the rest of OCI's catalog. One console, one bill, Oracle's infrastructure pricing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Oracle Database@Azure.&lt;/strong&gt; Oracle ships Exadata racks into Microsoft's datacenters and operates them there. You buy it in the &lt;strong&gt;Azure Marketplace&lt;/strong&gt;, provision it from the &lt;strong&gt;Azure portal&lt;/strong&gt;, and your apps in the same Azure region talk to it at in-datacenter latency. It is genuinely OCI — a slice of Oracle's cloud embedded in Azure — operated by Oracle's own OCI team, with support handled jointly by Oracle and Microsoft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The OCI–Azure Interconnect.&lt;/strong&gt; The database stays in OCI; your Azure workloads reach it over a private, pre-built link between paired OCI and Azure regions — &lt;strong&gt;a dozen region pairs, sub-2ms round-trip, and no ingress/egress charges over the link&lt;/strong&gt;. (You may also find references to &lt;em&gt;Oracle Database Service for Azure&lt;/em&gt; (ODSA), the 2022 portal-sync service built on this link — for new projects it's been superseded in practice by &lt;a href="mailto:Database@Azure"&gt;Database@Azure&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz2z4df6y9fpjg6ron089.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz2z4df6y9fpjg6ron089.png" alt="Choosing where an Oracle database lives. Follow app gravity first, then latency, then commercials — the database itself is the same everywhere." width="800" height="818"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Choosing where an Oracle database lives. Follow app gravity first, then latency, then commercials — the database itself is the same everywhere.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Database@Azure actually is
&lt;/h2&gt;

&lt;p&gt;The name undersells it. This isn't a connector or a managed VM — it's &lt;strong&gt;OCI hardware in Azure buildings&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The services&lt;/strong&gt;: Exadata Database Service on &lt;strong&gt;Dedicated Infrastructure&lt;/strong&gt; and on &lt;strong&gt;Exascale Infrastructure&lt;/strong&gt;, &lt;strong&gt;Autonomous Database&lt;/strong&gt; (Serverless and Dedicated), &lt;strong&gt;Base Database Service&lt;/strong&gt;, plus &lt;strong&gt;GoldenGate&lt;/strong&gt; and the &lt;strong&gt;Zero Data Loss Autonomous Recovery Service&lt;/strong&gt;. Oracle Database 19c and the current &lt;strong&gt;26ai&lt;/strong&gt; are supported — 26ai being 23ai renamed (a single Release Update makes the jump, no upgrade or recertification), which is also why you'll increasingly see the branding &lt;em&gt;Oracle AI Database@Azure&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The footprint&lt;/strong&gt;: available in &lt;strong&gt;33 Azure regions&lt;/strong&gt; and climbing — it more than doubled in the last year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The commercial model&lt;/strong&gt;: purchased through the &lt;strong&gt;Azure Marketplace&lt;/strong&gt;; consumption counts &lt;strong&gt;1:1 toward your Microsoft Azure Consumption Commitment (MACC)&lt;/strong&gt;. Your existing Oracle licenses apply — &lt;strong&gt;BYOL or license-included&lt;/strong&gt;, same as OCI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The operating model&lt;/strong&gt;: Oracle's OCI operations team runs the hardware, patching, and infrastructure; Microsoft runs the datacenter; you get &lt;strong&gt;Azure-portal provisioning&lt;/strong&gt; with &lt;strong&gt;Entra ID&lt;/strong&gt; identity and Azure-native monitoring hooks, while database internals still surface through an OCI console linked to your Azure tenancy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the honest fine print: &lt;strong&gt;day-2 operations span two control planes.&lt;/strong&gt; Provisioning, networking, and billing feel like Azure; the deep database machinery is still OCI underneath. Teams that expect a purely Azure experience find the seam; teams that know OCI find it familiar.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision factors
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;OCI native&lt;/th&gt;
&lt;th&gt;Database@Azure&lt;/th&gt;
&lt;th&gt;Interconnect (DB in OCI)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App-to-DB latency&lt;/td&gt;
&lt;td&gt;in-OCI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;same datacenter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;&amp;lt; 2ms&lt;/strong&gt; between paired regions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buys down Azure MACC&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes — 1:1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No (OCI spend)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle license BYOL&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service breadth&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Full OCI catalog&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Core DB services + GoldenGate + ZRCV&lt;/td&gt;
&lt;td&gt;Full OCI catalog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regions&lt;/td&gt;
&lt;td&gt;OCI's global footprint&lt;/td&gt;
&lt;td&gt;33 Azure regions&lt;/td&gt;
&lt;td&gt;12 paired regions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consoles&lt;/td&gt;
&lt;td&gt;OCI only&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Azure portal + OCI&lt;/strong&gt; (split)&lt;/td&gt;
&lt;td&gt;Azure + OCI (two estates)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical total cost&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Lowest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Parity on DB, Azure prices around it&lt;/td&gt;
&lt;td&gt;Low DB cost + free link egress&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;App gravity beats everything.&lt;/strong&gt; If the applications, integration layer, identity, and the team's tooling live in Azure — and the CIO's strategy says they stay there — the database follows the apps. Fighting app gravity with a cheaper database quote is how you end up owning a latency problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commercials decide more of this than architects like to admit.&lt;/strong&gt; A large enterprise with a MACC to burn and an Azure-first mandate has an easy business case for Database@Azure: the same Oracle spend now counts against the Microsoft commitment. Conversely, if you're not carrying Azure commitments, OCI native is usually the cheaper estate — Oracle's compute, storage, and egress pricing undercut the equivalent hyperscaler line items, and that's before you price the second cloud's networking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency is the tiebreaker, not the headline.&lt;/strong&gt; Sub-2ms over the Interconnect is fine for most applications — batch, reporting, services with sane call patterns. Where it isn't fine is chatty OLTP: an app that makes hundreds of sequential database round-trips per user action multiplies every one of those milliseconds. Measure your app's round-trip count before you let anyone tell you 2ms is nothing — &lt;a href="https://uptimearchitect.com/blog/oracle-wait-events-decoded/" rel="noopener noreferrer"&gt;wait events don't lie&lt;/a&gt;, and &lt;code&gt;SQL*Net message from client&lt;/code&gt; on the database side of a chatty app is exactly where this shows up. (You can &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/latency" rel="noopener noreferrer"&gt;measure this multiplication yourself&lt;/a&gt; — see the lab below.)&lt;/p&gt;

&lt;h2&gt;
  
  
  When each one wins
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;OCI native wins&lt;/strong&gt; when you're not anchored to Azure: greenfield builds, estates consolidating on Oracle's stack, Exadata Cloud@Customer hybrids, cost-driven migrations, and anywhere you want the whole OCI catalog next to the database. It's also the simplest &lt;em&gt;operationally&lt;/em&gt; — one console, one support path, no seam.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database@Azure wins&lt;/strong&gt; when the application estate is committed to Azure and the app-to-database path matters: same-datacenter latency, MACC burn-down, Azure-portal provisioning, Entra ID, and Exadata/Autonomous without leaving the building. It's the answer that lets an Azure-first enterprise run &lt;em&gt;real&lt;/em&gt; Oracle — &lt;a href="https://uptimearchitect.com/blog/oracle-cloud-migration-methods/" rel="noopener noreferrer"&gt;not a compromise port&lt;/a&gt; — and every migration method works against it, because it's Exadata under the hood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Interconnect wins&lt;/strong&gt; when you want OCI's pricing and catalog &lt;em&gt;and&lt;/em&gt; Azure apps, and the app can tolerate single-digit milliseconds. Database in OCI, apps in Azure, private link, no egress fees over it. This is also the natural &lt;strong&gt;DR pattern&lt;/strong&gt;: primary in one cloud, &lt;a href="https://uptimearchitect.com/blog/oracle-data-guard-switchover-vs-failover/" rel="noopener noreferrer"&gt;Data Guard standby&lt;/a&gt; across the link in the other — a cross-cloud failover story that doesn't require moving the estate.&lt;/p&gt;

&lt;p&gt;And if your apps live on AWS or Google Cloud: the identical decision now exists there. &lt;strong&gt;Database@AWS&lt;/strong&gt; and &lt;strong&gt;Database@Google Cloud&lt;/strong&gt; are both generally available — Oracle-operated Exadata inside the other hyperscalers' datacenters — and AWS's own &lt;strong&gt;AWS Interconnect&lt;/strong&gt; added OCI connectivity in preview in 2026. Swap the vendor names and this whole article still applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What teams get wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deciding on database features.&lt;/strong&gt; The database is identical in all three options. The decision is about apps, contracts, latency, and operations — never about the engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring the split control plane.&lt;/strong&gt; Database@Azure provisioning is Azure; deep database operations are OCI. Budget for your team learning both, or the first incident will do the teaching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating 2ms as free.&lt;/strong&gt; For chatty OLTP, interconnect latency compounds per round-trip. Profile the app's call pattern first; move the database into the same building only if the numbers say so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving MACC on the table.&lt;/strong&gt; If the company has an Azure commitment, Database@Azure spend counts toward it 1:1 — finance may like the "more expensive" option better than your spreadsheet does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting the Interconnect exists.&lt;/strong&gt; Not every Azure app needs the database &lt;em&gt;in&lt;/em&gt; Azure. The middle path keeps OCI economics with Azure proximity — and nobody promotes it, because neither vendor's sales team owns it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming Azure ends the DR conversation.&lt;/strong&gt; Wherever the database lands, it still needs &lt;a href="https://uptimearchitect.com/blog/oracle-ha-decision-tree-rac-vs-data-guard/" rel="noopener noreferrer"&gt;a protection architecture&lt;/a&gt; — Data Guard across regions (or clouds), backups, and &lt;a href="https://uptimearchitect.com/blog/oracle-rman-recovery-runbook/" rel="noopener noreferrer"&gt;a tested restore&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one-paragraph version
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Follow the apps.&lt;/strong&gt; If they're committed to Azure and the app-to-database path is chatty, put the database in the same building — &lt;strong&gt;Database@Azure&lt;/strong&gt;, which is real Oracle-operated Exadata inside Azure, bought through the Marketplace and burning down your MACC. If they're committed to Azure but the path tolerates a few milliseconds, consider the &lt;strong&gt;Interconnect&lt;/strong&gt; — database in OCI at OCI prices, sub-2ms away, no egress over the link. If the apps aren't anchored to Azure, &lt;strong&gt;OCI native&lt;/strong&gt; is the default: full catalog, one console, lowest total cost. The engine is identical everywhere — so decide on app gravity, commercial commitments, and latency, in that order. The same template now answers the AWS and Google Cloud versions of this question, too.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Measure it before you decide it.&lt;/strong&gt; The &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/latency" rel="noopener noreferrer"&gt;&lt;code&gt;latency/&lt;/code&gt; lab&lt;/a&gt; runs this post's chatty-vs-batched experiment on your own machine: Oracle Database Free in Docker, with &lt;code&gt;tc netem&lt;/code&gt; injecting the 0ms / 2ms / 10ms of the three homes above. Same database, same rows — the chatty workload went from &lt;strong&gt;0.7s to 3.6s to 11.8s&lt;/strong&gt; as the latency rose, while the batched one barely moved (0.1s → 0.3s). The round-trip counts come from &lt;code&gt;v$mystat&lt;/code&gt;, and the run fails unless the elapsed times actually match the injected delay — so it only passes if the math is real. Chatty apps don't have a cloud problem; they have a round-trip problem that latency exposes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Want to try the OCI side for free?&lt;/strong&gt; &lt;a href="https://uptimearchitect.com/blog/oracle-autonomous-database-oci-always-free/" rel="noopener noreferrer"&gt;Oracle Autonomous Database on OCI Always Free&lt;/a&gt; gives you a real, fully managed 26ai database — no license, no server, no cost — and it's the same Autonomous service that runs inside &lt;a href="mailto:Database@Azure"&gt;Database@Azure&lt;/a&gt;. Standing one up is the fastest way to feel how the OCI half of this decision operates before you commit an estate to it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is Oracle Database@Azure?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Oracle Database@Azure is Oracle-operated database infrastructure — Exadata Database Service (Dedicated and Exascale), Autonomous Database (Serverless and Dedicated), Base Database Service, GoldenGate, and the Zero Data Loss Autonomous Recovery Service — running on Oracle hardware physically located inside Microsoft Azure datacenters. You purchase it through the Azure Marketplace, provision it from the Azure portal, and your Azure applications reach it at in-datacenter latency. It is available in 33 Azure regions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who operates Database@Azure — Microsoft or Oracle?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Oracle. The hardware is owned, patched, and operated by Oracle Cloud Infrastructure personnel; Microsoft provides the datacenter, the Azure-side networking, and the portal integration. Support is handled jointly, so you can raise issues through either vendor. Day to day you provision from the Azure portal, while deeper database operations surface through an OCI console linked to your Azure tenancy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Database@Azure spend count toward my Azure commitment (MACC)?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Oracle Database@Azure is purchased through the Azure Marketplace and consumption counts 1:1 toward a Microsoft Azure Consumption Commitment. For organizations carrying a large MACC, that often makes the business case by itself — the Oracle database spend reduces the Microsoft commitment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I bring my own Oracle licenses to Database@Azure or OCI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, both. Database@Azure and OCI database services support bring-your-own-license (BYOL) as well as license-included pricing, so existing Oracle Database licenses and support agreements carry over to either destination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between Database@Azure and the OCI–Azure Interconnect?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Database@Azure puts the database physically inside the Azure datacenter — same-building latency, Azure Marketplace billing, MACC burn-down. The Interconnect keeps the database in OCI and links it privately to a paired Azure region at under 2 milliseconds round-trip with no ingress/egress charges over the link. The Interconnect preserves OCI pricing and the full OCI catalog; Database@Azure wins when the app-to-database path is latency-critical or the commercials favor Azure. The older Oracle Database Service for Azure (ODSA) portal service has been superseded in practice by Database@Azure for new projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Database@Azure more expensive than running in OCI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The database services are priced comparably, but the estate around them usually is not: OCI compute, storage, and egress typically cost less than the Azure equivalents, so OCI native tends to have the lowest total cost. Database@Azure changes the calculation when a MACC is in play, because the spend burns down an existing Microsoft commitment — the accounting can favor it even when the sticker price does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I migrate an existing Oracle database into Database@Azure?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same way you would migrate to Exadata in OCI — because it is Exadata. All the standard methods apply: Data Pump for simple moves with an outage, Data Guard for near-zero-downtime same-endian migrations with easy rollback, transportable tablespaces with RMAN CONVERT for very large or cross-endian moves, GoldenGate when you are crossing versions or need a fallback, and ZDM to automate whichever fits. Autonomous targets accept logical methods only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do Database@AWS and Database@Google Cloud work the same way?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes — both are generally available and follow the same model: Oracle-operated Exadata and Autonomous services physically inside AWS and Google Cloud datacenters, purchased through the respective marketplaces and counting toward those clouds' commitments. AWS's own AWS Interconnect service added OCI connectivity in preview in 2026. The decision framework — app gravity, commercial commitments, then latency — is identical; only the vendor names change.&lt;/p&gt;

&lt;p&gt;Wherever the database lands, the architecture questions that follow are the same ones this blog keeps coming back to: &lt;a href="https://uptimearchitect.com/blog/oracle-cloud-migration-methods/" rel="noopener noreferrer"&gt;which migration method&lt;/a&gt; gets you there with the downtime you can afford, &lt;a href="https://uptimearchitect.com/blog/oracle-ha-decision-tree-rac-vs-data-guard/" rel="noopener noreferrer"&gt;which protection architecture&lt;/a&gt; keeps it alive once it's there, and &lt;a href="https://uptimearchitect.com/blog/oracle-rman-recovery-runbook/" rel="noopener noreferrer"&gt;whether you can prove a restore&lt;/a&gt; on the worst day. The cloud changes the building; it doesn't change the job.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-database-at-azure-vs-oci/" rel="noopener noreferrer"&gt;uptimearchitect.com&lt;/a&gt;. I write here in a personal capacity — questions or feedback are welcome via the &lt;a href="https://uptimearchitect.com/contact/" rel="noopener noreferrer"&gt;contact page&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>database</category>
      <category>azure</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
