<?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 Privilege Analysis: You Granted DBA. Here's What They Actually Used.</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sat, 19 Sep 2026 12:45:34 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-privilege-analysis-you-granted-dba-heres-what-they-actually-used-258b</link>
      <guid>https://dev.to/uptimearchitect/oracle-privilege-analysis-you-granted-dba-heres-what-they-actually-used-258b</guid>
      <description>&lt;p&gt;Someone needed to read one table. The ticket said "just give the app the standard role so we can ship," the role already carried half a dozen &lt;code&gt;ANY&lt;/code&gt; privileges, and it got granted. Three years later that account can &lt;code&gt;DROP ANY TABLE&lt;/code&gt; in production and nobody alive remembers whether it needs to. Multiply by every service account, every "temporary" grant that became permanent, every role that grew by accretion, and you get the database most shops actually run: a pile of privilege nobody can safely revoke, because nobody can prove what's in use.&lt;/p&gt;

&lt;p&gt;You don't have to guess. &lt;strong&gt;Privilege Analysis&lt;/strong&gt; — the &lt;code&gt;DBMS_PRIVILEGE_CAPTURE&lt;/code&gt; package, built into Enterprise Edition — records the privileges a session &lt;em&gt;actually exercises&lt;/em&gt; over a window of real activity. Afterward you compare &lt;strong&gt;used&lt;/strong&gt; against &lt;strong&gt;granted&lt;/strong&gt;, and the difference is your revoke list: privileges held but never exercised, removable with evidence instead of a nervous guess. This post is what a capture is, how to run one, the one finding it produces almost every time (&lt;code&gt;SELECT ANY TABLE&lt;/code&gt;, unused), and a lab that grants a user a dozen privileges, watches it use a handful, and prints the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Privilege Analysis actually records
&lt;/h2&gt;

&lt;p&gt;A privilege capture watches sessions and notes every privilege that was &lt;em&gt;needed to authorize an operation&lt;/em&gt; — the &lt;code&gt;CREATE TABLE&lt;/code&gt; behind a &lt;code&gt;CREATE TABLE&lt;/code&gt; statement, the &lt;code&gt;SELECT&lt;/code&gt; object grant behind a query, the system privilege behind a DDL. It does not record what was granted; the data dictionary already knows that. It records what was &lt;strong&gt;exercised&lt;/strong&gt;. Run a representative workload — a month-end, a full release cycle, a busy week — and you have an evidence-based map of the privileges an account can't do its job without.&lt;/p&gt;

&lt;p&gt;Then you subtract. Everything granted but not captured is, by definition, unused over that window. That set is the least-privilege candidate list: revoke it and the account keeps working, minus the blast radius you were carrying for no reason. The whole discipline is &lt;em&gt;"grant what's used, revoke what isn't"&lt;/em&gt; — and Privilege Analysis is what turns that from a slogan into a query.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a capture works
&lt;/h2&gt;

&lt;p&gt;Four steps, all through &lt;code&gt;DBMS_PRIVILEGE_CAPTURE&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;-- 1. define a capture. G_CONTEXT scopes it to just the sessions you care about.&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_PRIVILEGE_CAPTURE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CREATE_CAPTURE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'APPUSER_CAP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'What APPUSER actually uses vs what it was granted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_PRIVILEGE_CAPTURE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;G_CONTEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;condition&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SYS_CONTEXT(&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;USERENV&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;SESSION_USER&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;) = &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;APPUSER&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. turn it on — BEFORE the workload runs&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_PRIVILEGE_CAPTURE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ENABLE_CAPTURE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'APPUSER_CAP'&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;-- 3. ... the application runs its real workload as APPUSER ...&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. stop capturing and compute the result&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_PRIVILEGE_CAPTURE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DISABLE_CAPTURE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'APPUSER_CAP'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_PRIVILEGE_CAPTURE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GENERATE_RESULT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'APPUSER_CAP'&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;The step people miss is &lt;code&gt;GENERATE_RESULT&lt;/code&gt;: until you call it, the &lt;code&gt;DBA_USED_PRIVS&lt;/code&gt; / &lt;code&gt;DBA_UNUSED_PRIVS&lt;/code&gt; views (and their &lt;code&gt;*_SYSPRIVS&lt;/code&gt; / &lt;code&gt;*_OBJPRIVS&lt;/code&gt; siblings) are empty. Enable, run the workload, disable, &lt;em&gt;generate&lt;/em&gt; — then read the views. And there are four capture &lt;strong&gt;types&lt;/strong&gt; for scoping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;G_DATABASE&lt;/code&gt;&lt;/strong&gt; — every privilege use in the whole database. The broad sweep; only one can run at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;G_ROLE&lt;/code&gt;&lt;/strong&gt; — only privileges that come from a named set of roles. Perfect for "is anyone actually using what &lt;code&gt;DBA&lt;/code&gt; grants?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;G_CONTEXT&lt;/code&gt;&lt;/strong&gt; — a SQL condition you supply, evaluated per session (the lab keys off &lt;code&gt;SESSION_USER&lt;/code&gt; so it captures exactly one account).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;G_ROLE_AND_CONTEXT&lt;/code&gt;&lt;/strong&gt; — both, ANDed together.&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%2Fmermaid.ink%2Fimg%2Fpako%3AeNplklFr2zAUhf_KQa9rWRrGBmXrcGytBDLHyPZg1CMo9k1iqlhFkpONJP-9sutk3broY93znXukAyt1RewWbKX0vtxI45BFRQMEDwW7N7JxkGVpyJH9vDTv74xWhCRYiPmM4wtuxngy9a5WtCZbsF-4vr7DxI-GggcZX4RBkuWC4x14HExmF6H3sqV-ogpOI0iSPOXCG3ToSe8SepdBh2kbCwlDUmGvzaPSsuotlF6jblC0o9HyU_e_Qpin2fw7F-lZ9emlI-h9AyeXigZK2FMiT4mm6etsPuw9j7no8gue5rNsmIj6CX4o2DdtQLLcYN015He4lHDbx9pLi9qBfpMpa0vV14KdOgfeORz_kD0i78CTYOH3ixaJmP5I-8mX3pDyNJ3O4_MKg5p1Mc9aymc8zKCbvxsPOV8ojT4iPkPi_zDDcBD__Nc0EvPkrTrg3-hFOx6NPw7UuG9HeGK2IX8VO_1IULV1_bGbD1Ak_celqSusjN6CdnVFTUlotMO6JWu7C_ae7ApsS2Yr68q_zwNzG9r2L7WilWyVY6fTM6oH1o8" 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%3AeNplklFr2zAUhf_KQa9rWRrGBmXrcGytBDLHyPZg1CMo9k1iqlhFkpONJP-9sutk3broY93znXukAyt1RewWbKX0vtxI45BFRQMEDwW7N7JxkGVpyJH9vDTv74xWhCRYiPmM4wtuxngy9a5WtCZbsF-4vr7DxI-GggcZX4RBkuWC4x14HExmF6H3sqV-ogpOI0iSPOXCG3ToSe8SepdBh2kbCwlDUmGvzaPSsuotlF6jblC0o9HyU_e_Qpin2fw7F-lZ9emlI-h9AyeXigZK2FMiT4mm6etsPuw9j7no8gue5rNsmIj6CX4o2DdtQLLcYN015He4lHDbx9pLi9qBfpMpa0vV14KdOgfeORz_kD0i78CTYOH3ixaJmP5I-8mX3pDyNJ3O4_MKg5p1Mc9aymc8zKCbvxsPOV8ojT4iPkPi_zDDcBD__Nc0EvPkrTrg3-hFOx6NPw7UuG9HeGK2IX8VO_1IULV1_bGbD1Ak_celqSusjN6CdnVFTUlotMO6JWu7C_ae7ApsS2Yr68q_zwNzG9r2L7WilWyVY6fTM6oH1o8" alt="A privilege capture, end to end. You enable it, the account runs a real workload, you disable and generate the result." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A privilege capture, end to end. You enable it, the account runs a real workload, you disable and generate the result — and every granted privilege sorts into USED (keep it) or UNUSED (the revoke list). The value is the second pile: privileges held but never exercised, safe to remove because the evidence says so.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding you'll get almost every time: &lt;code&gt;SELECT ANY TABLE&lt;/code&gt;, unused
&lt;/h2&gt;

&lt;p&gt;There's one result Privilege Analysis surfaces so reliably it's worth calling out. When an account holds both a specific object grant (&lt;code&gt;SELECT&lt;/code&gt; on one table) &lt;strong&gt;and&lt;/strong&gt; the system privilege &lt;code&gt;SELECT ANY TABLE&lt;/code&gt;, and it queries that table, Oracle authorizes the read with the &lt;em&gt;object&lt;/em&gt; grant. The &lt;code&gt;ANY&lt;/code&gt; privilege is never touched — so it lands in &lt;code&gt;DBA_UNUSED_PRIVS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the single most common over-grant in the wild: someone was handed the god-mode "read every table in the database" privilege when all they ever read was the two tables they were explicitly granted. Privilege Analysis catches it in black and white, which is exactly the ammunition you need to revoke it over the inevitable "but what if something breaks" objection. Nothing was using it. The evidence says so.&lt;/p&gt;

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

&lt;p&gt;Grant a user a role that carries &lt;strong&gt;twelve&lt;/strong&gt; privileges — ten system privileges including &lt;code&gt;SELECT ANY TABLE&lt;/code&gt;, &lt;code&gt;DROP ANY TABLE&lt;/code&gt;, &lt;code&gt;CREATE ANY TABLE&lt;/code&gt;, and &lt;code&gt;ALTER ANY TABLE&lt;/code&gt;, plus &lt;code&gt;SELECT&lt;/code&gt; on two tables. Start a capture scoped to that user. Then have it do three ordinary things: log in, read one table it has an explicit grant on, and create one table in its own schema. Disable, generate, and read the result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Privileges&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;USED&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CREATE SESSION&lt;/code&gt;, &lt;code&gt;CREATE TABLE&lt;/code&gt;, &lt;code&gt;SELECT&lt;/code&gt; on &lt;code&gt;PAOWN.CUSTOMERS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UNUSED&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SELECT ANY TABLE&lt;/code&gt;, &lt;code&gt;DROP ANY TABLE&lt;/code&gt;, &lt;code&gt;CREATE ANY TABLE&lt;/code&gt;, &lt;code&gt;ALTER ANY TABLE&lt;/code&gt;, &lt;code&gt;CREATE VIEW&lt;/code&gt;, &lt;code&gt;CREATE PROCEDURE&lt;/code&gt;, &lt;code&gt;CREATE SEQUENCE&lt;/code&gt;, &lt;code&gt;CREATE SYNONYM&lt;/code&gt;, &lt;code&gt;SELECT&lt;/code&gt; on &lt;code&gt;PAOWN.ORDERS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three privileges did the work. &lt;strong&gt;Nine sat unused&lt;/strong&gt; — every &lt;code&gt;ANY&lt;/code&gt; privilege among them. &lt;code&gt;SELECT ANY TABLE&lt;/code&gt; is unused because the one table the account read, it was explicitly granted; the read never needed the god-mode version. &lt;code&gt;DROP ANY TABLE&lt;/code&gt; and friends are unused because the account never dropped or altered anything outside its own schema. &lt;code&gt;SELECT&lt;/code&gt; on &lt;code&gt;PAOWN.ORDERS&lt;/code&gt; is unused because the workload simply never touched that table. The least-privilege regrant writes itself: keep the three, revoke the nine.&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/privilege-analysis" rel="noopener noreferrer"&gt;Privilege Analysis lab&lt;/a&gt; stands up an Oracle Database Free container, grants &lt;code&gt;APPUSER&lt;/code&gt; the 12-privilege role, enables a &lt;code&gt;DBMS_PRIVILEGE_CAPTURE&lt;/code&gt; run scoped to it, executes the small workload, then generates the result and &lt;strong&gt;asserts&lt;/strong&gt; the split: &lt;code&gt;CREATE SESSION&lt;/code&gt; / &lt;code&gt;CREATE TABLE&lt;/code&gt; / &lt;code&gt;SELECT&lt;/code&gt; on &lt;code&gt;CUSTOMERS&lt;/code&gt; come back &lt;em&gt;used&lt;/em&gt;; &lt;code&gt;SELECT ANY TABLE&lt;/code&gt;, &lt;code&gt;DROP ANY TABLE&lt;/code&gt;, &lt;code&gt;CREATE ANY TABLE&lt;/code&gt;, and the rest come back &lt;em&gt;unused&lt;/em&gt;; and there are more unused than used. If the capture stops telling them apart, the run fails. It's 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;Guessing the revoke list instead of measuring it.&lt;/strong&gt; The reason over-grants never get cleaned up is fear: nobody will revoke a privilege they can't prove is unused. Privilege Analysis removes the fear — you revoke from a captured-usage report, not a hunch. Measure first, then cut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too short a capture window.&lt;/strong&gt; If you capture for an afternoon, month-end batch jobs, quarterly reports, and the annual archive purge never run — and their privileges look "unused" right up until you revoke them and break the quarter close. Capture across a &lt;em&gt;representative&lt;/em&gt; period, and think about the rare-but-real jobs before you cut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;GENERATE_RESULT&lt;/code&gt;.&lt;/strong&gt; Reading &lt;code&gt;DBA_UNUSED_PRIVS&lt;/code&gt; and seeing nothing, then concluding the account used everything. The views are empty until you generate the result for the capture. Disable → generate → read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revoking the privilege but leaving the role.&lt;/strong&gt; If the unused privilege comes through a role, revoking it from the account does nothing — it's still in the role. Privilege Analysis tells you &lt;em&gt;how&lt;/em&gt; each privilege was granted (directly, or via which role); fix it at the grant path the report names, and remember the role is shared, so trimming it affects everyone who holds it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating one capture as forever.&lt;/strong&gt; Usage changes: a new feature ships, a new report is added, and yesterday's "unused" becomes today's needed. Privilege Analysis is a periodic hygiene practice, not a one-time audit — recapture after significant change, and before you trust a stale result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confusing it with auditing.&lt;/strong&gt; Privilege Analysis tells you which privileges &lt;em&gt;could be revoked&lt;/em&gt;; it is not a record of &lt;em&gt;who did what&lt;/em&gt; — that's &lt;a href="https://uptimearchitect.com/blog/oracle-unified-auditing/" rel="noopener noreferrer"&gt;unified auditing&lt;/a&gt;. Use analysis to shrink the grants, auditing to watch how the remaining ones get used. Different jobs.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What is Oracle Privilege Analysis?
&lt;/h3&gt;

&lt;p&gt;Privilege Analysis is an Oracle Database feature, driven by the DBMS_PRIVILEGE_CAPTURE package, that records the privileges a database session actually exercises over a period of activity. Instead of listing what an account was granted (which the data dictionary already shows), it captures what was used to authorize real operations, so you can compare used privileges against granted ones and identify the granted-but-never-used privileges that are candidates for revocation. It is the practical mechanism for enforcing least privilege on an existing database: rather than guessing which grants are safe to remove, you revoke based on captured evidence of what each account needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I run a privilege capture with DBMS_PRIVILEGE_CAPTURE?
&lt;/h3&gt;

&lt;p&gt;Four steps. First, CREATE_CAPTURE to define the capture, giving it a name, a type (G_DATABASE, G_ROLE, G_CONTEXT, or G_ROLE_AND_CONTEXT), and for a context capture a SQL condition that scopes which sessions are recorded. Second, ENABLE_CAPTURE to start recording — this must happen before the workload you want to measure runs. Third, let a representative workload run for as long as it takes to exercise the account's real activity. Fourth, DISABLE_CAPTURE to stop and then GENERATE_RESULT to compute the outcome, which populates the reporting views. Only after GENERATE_RESULT do DBA_USED_PRIVS and DBA_UNUSED_PRIVS contain data for that capture.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the four privilege capture types?
&lt;/h3&gt;

&lt;p&gt;G_DATABASE captures every privilege use across the whole database and is the broad sweep; only one database-wide capture can be enabled at a time. G_ROLE captures only privilege uses that derive from a specified set of roles, which is ideal for questions like whether anyone actually exercises what a powerful role grants. G_CONTEXT captures based on a SQL condition you supply that is evaluated per session, for example limiting the capture to a single application user or to connections from a particular program or IP. G_ROLE_AND_CONTEXT combines the role and context conditions so both must hold. Context and role captures can run alongside the always-present ORA$DEPENDENCY capture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does SELECT ANY TABLE show up as an unused privilege?
&lt;/h3&gt;

&lt;p&gt;When a session holds both a specific object privilege, such as SELECT on one table, and the system privilege SELECT ANY TABLE, and it queries that table, Oracle authorizes the operation using the object privilege. The system-wide SELECT ANY TABLE is not needed for that access, so Privilege Analysis records it as unused. In practice this is the most common over-grant it surfaces: accounts are frequently given SELECT ANY TABLE when they only ever read a small set of tables they were explicitly granted, so the powerful any-table privilege sits unexercised and is safe to revoke. The same pattern applies to other ANY privileges when narrower grants already cover the actual work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which views show the results of a privilege capture?
&lt;/h3&gt;

&lt;p&gt;After GENERATE_RESULT, the used privileges appear in DBA_USED_PRIVS with system-privilege and object-privilege detail also split into DBA_USED_SYSPRIVS and DBA_USED_OBJPRIVS, and the granted-but-unused privileges appear in DBA_UNUSED_PRIVS with the corresponding DBA_UNUSED_SYSPRIVS and DBA_UNUSED_OBJPRIVS. There are also path views such as DBA_USED_PRIVS showing how a privilege was granted (directly or through which role) and role-usage views. The capture definitions themselves are listed in DBA_PRIV_CAPTURES. You typically filter these views by the capture name, and, since a context capture already scopes to specific sessions, the rows correspond to the account or accounts the capture targeted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Privilege Analysis capture privileges used through roles?
&lt;/h3&gt;

&lt;p&gt;Yes. Privilege Analysis records a privilege as used regardless of whether the account holds it directly or receives it through a role (including nested roles), and the reporting views show the grant path so you can see how each used or unused privilege reached the account. This matters for remediation: if an unused privilege comes through a role, revoking it directly from the user does nothing because the role still carries it, so you have to change the role, and because roles are shared, trimming a role affects every account that holds it. The report gives you the information to decide whether to adjust the role, replace it with a narrower one, or grant the small used set directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Privilege Analysis require the Database Vault option or a separate license?
&lt;/h3&gt;

&lt;p&gt;No longer. When Privilege Analysis was introduced in Oracle Database 12c it was part of the Oracle Database Vault option, but since Oracle Database 18c it is a feature of Enterprise Edition and does not require Database Vault or a separate license. It is also available in Oracle Database Free, which is built on the Enterprise Edition feature set, so you can run captures for development and testing at no cost, which is what makes the companion lab reproducible with nothing but Docker. As always, confirm the entitlement for your specific edition, version, and platform before relying on it in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the limits of Privilege Analysis I should plan around?
&lt;/h3&gt;

&lt;p&gt;The biggest one is coverage: a capture only knows about activity that happened while it was enabled, so a window that misses periodic work — month-end, quarter close, annual purges, rarely used admin paths — will report privileges as unused that are actually needed on a cadence you didn't observe. Capture across a representative period and account for rare jobs before revoking. Beyond that, treat results as a point-in-time snapshot that goes stale as the application changes, so recapture periodically; remediate at the correct grant path (role versus direct) rather than assuming a direct revoke works; and remember that Privilege Analysis identifies removable privileges but is not an audit trail of who did what, which is a separate concern handled by auditing.&lt;/p&gt;

&lt;p&gt;Privilege Analysis is the layer of database security most shops skip because it feels impossible to do safely — so the grants only ever grow. It is the natural companion to the rest of the discipline: &lt;a href="https://uptimearchitect.com/blog/oracle-database-hardening-checklist/" rel="noopener noreferrer"&gt;the hardening checklist&lt;/a&gt; sets the baseline for who can connect and how, &lt;a href="https://uptimearchitect.com/blog/oracle-vpd-row-level-security/" rel="noopener noreferrer"&gt;VPD&lt;/a&gt; decides which rows each session sees, &lt;a href="https://uptimearchitect.com/blog/oracle-data-redaction-mask-at-read-time/" rel="noopener noreferrer"&gt;redaction&lt;/a&gt; narrows which values reach a screen, &lt;a href="https://uptimearchitect.com/blog/oracle-transparent-data-encryption-tde/" rel="noopener noreferrer"&gt;TDE&lt;/a&gt; protects the files, and &lt;a href="https://uptimearchitect.com/blog/oracle-unified-auditing/" rel="noopener noreferrer"&gt;unified auditing&lt;/a&gt; records who looked. Privilege Analysis works on the grants underneath all of them — shrinking what every account &lt;em&gt;can&lt;/em&gt; do down to what it demonstrably &lt;em&gt;needs&lt;/em&gt; to. Capture a representative window, generate the result, and revoke the unused pile with the evidence in hand. Then prove it the way that ends the argument: with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/privilege-analysis" rel="noopener noreferrer"&gt;Privilege Analysis lab&lt;/a&gt;, where an account granted a dozen privileges is shown using just three.&lt;/p&gt;




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

</description>
      <category>oracle</category>
      <category>database</category>
      <category>security</category>
      <category>leastprivilege</category>
    </item>
    <item>
      <title>Oracle Virtual Private Database: Row-Level Security You Can't Route Around</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Wed, 16 Sep 2026 12:27:58 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-virtual-private-database-row-level-security-you-cant-route-around-1725</link>
      <guid>https://dev.to/uptimearchitect/oracle-virtual-private-database-row-level-security-you-cant-route-around-1725</guid>
      <description>&lt;p&gt;You wrote a view that filters each sales rep to their own region, granted the view, revoked the table, and called it row-level security. It worked — until the reporting team needed the base table, or a data export ran as a privileged account, or someone wrote an ad-hoc query straight against &lt;code&gt;ORDERS&lt;/code&gt;. The moment anyone touches the table instead of the view, the filter is gone. A view is a &lt;em&gt;detour&lt;/em&gt;, and a detour only works if everyone agrees to take it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtual Private Database&lt;/strong&gt; (VPD, also called Fine-Grained Access Control, implemented through the &lt;code&gt;DBMS_RLS&lt;/code&gt; package) does it the other way around: it attaches the rule to the &lt;strong&gt;table&lt;/strong&gt;. You write a policy function that returns a &lt;code&gt;WHERE&lt;/code&gt; predicate, and Oracle &lt;strong&gt;transparently appends that predicate to every query against the table&lt;/strong&gt;, per session — no view, no application change, and nothing to route around. Query the base table directly and the predicate is &lt;em&gt;still there&lt;/em&gt;. This is the row-level counterpart to &lt;a href="https://uptimearchitect.com/blog/oracle-data-redaction-mask-at-read-time/" rel="noopener noreferrer"&gt;Data Redaction&lt;/a&gt;: redaction masks a column's &lt;em&gt;value&lt;/em&gt; and leaves the row; VPD removes the &lt;em&gt;row&lt;/em&gt; entirely.&lt;/p&gt;

&lt;p&gt;This post is what VPD actually is, how the policy function and the exempt privilege work, where it applies (and where people forget it doesn't), and — the part that ends the argument — a lab where three users query the same table and each one provably sees only the rows they're allowed to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What VPD is — a predicate the database adds for you
&lt;/h2&gt;

&lt;p&gt;A VPD policy has two pieces: a &lt;strong&gt;policy function&lt;/strong&gt; and the &lt;strong&gt;&lt;code&gt;DBMS_RLS.ADD_POLICY&lt;/code&gt;&lt;/strong&gt; call that binds it to a table. The function takes the schema and object name and returns a string — a SQL predicate. When a session queries the protected table, Oracle calls the function, takes whatever predicate it returns, and rewrites the query to include it. A &lt;code&gt;SELECT * FROM orders&lt;/code&gt; issued by a sales rep silently becomes &lt;code&gt;SELECT * FROM orders WHERE (region = 'EAST')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The crucial word is &lt;em&gt;transparently&lt;/em&gt;. The application doesn't know, the SQL text doesn't change, and the predicate is enforced on the &lt;strong&gt;base table&lt;/strong&gt; — so it applies to &lt;code&gt;SELECT COUNT(*)&lt;/code&gt;, to &lt;code&gt;SUM(amount)&lt;/code&gt;, to a join, to a &lt;code&gt;WHERE id = 42&lt;/code&gt; lookup, to an export, to everything. There is no version of the query that sees the unfiltered table (except for the exempt sessions below). That's the whole difference from a view: a view is a separate object you can choose not to use; a VPD predicate is part of every statement against the table itself.&lt;/p&gt;

&lt;p&gt;Here's the function — it keys off the session user and returns each rep's region:&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="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;vpdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders_rls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_schema&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="n"&gt;VARCHAR2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_object&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="n"&gt;VARCHAR2&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;VARCHAR2&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="n"&gt;SYS_CONTEXT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'USERENV'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'SESSION_USER'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;'REP_EAST'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'region = &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;EAST&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;
           &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="s1"&gt;'REP_WEST'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'region = &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;WEST&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;
           &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'1=2'&lt;/span&gt;                       &lt;span class="c1"&gt;-- deny by default: unknown users see no rows&lt;/span&gt;
         &lt;span class="k"&gt;END&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;And the policy that binds it to the table, for &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="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_RLS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ADD_POLICY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;object_schema&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'VPDOWN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;object_name&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;policy_name&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ORDERS_REGION_POLICY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;function_schema&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'VPDOWN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;policy_function&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ORDERS_RLS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;statement_types&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SELECT'&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;That's it. No grants to juggle, no view to maintain, no trigger. From now on every &lt;code&gt;SELECT&lt;/code&gt; against &lt;code&gt;VPDOWN.ORDERS&lt;/code&gt; carries the predicate the function hands back for that session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key the predicate off something the user can't forge
&lt;/h2&gt;

&lt;p&gt;The policy function above uses &lt;code&gt;SYS_CONTEXT('USERENV','SESSION_USER')&lt;/code&gt; — the database account the session logged in as, which a user cannot spoof. That's the safe kind of input. The same rule applies here as in the &lt;a href="https://uptimearchitect.com/blog/oracle-data-redaction-mask-at-read-time/" rel="noopener noreferrer"&gt;redaction post&lt;/a&gt;: &lt;strong&gt;base the decision on a session fact the user can't set themselves.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the real world, app servers often connect through one pooled database account and identify the end user separately. There the standard pattern is an &lt;strong&gt;application context&lt;/strong&gt;: a logon trigger (or the connection pool) sets a context attribute like &lt;code&gt;app_ctx.region&lt;/code&gt; for the session, and the policy function reads &lt;code&gt;SYS_CONTEXT('APP_CTX','REGION')&lt;/code&gt;. Done right, the context is set by a trusted package the user can't call directly, so it's just as unforgeable as &lt;code&gt;SESSION_USER&lt;/code&gt;. Done wrong — keying off a client identifier the application sets for convenience — it's a lock anyone can open by setting the same value. Same trap, same fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who bypasses the policy
&lt;/h2&gt;

&lt;p&gt;Two kinds of session are not subject to VPD:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SYS&lt;/code&gt;&lt;/strong&gt; (and &lt;code&gt;SYSDBA&lt;/code&gt; connections) are always exempt — they see the raw table.&lt;/li&gt;
&lt;li&gt;Any user holding the &lt;strong&gt;&lt;code&gt;EXEMPT ACCESS POLICY&lt;/code&gt;&lt;/strong&gt; system privilege bypasses &lt;em&gt;every&lt;/em&gt; VPD policy in the database, regardless of any function. This is how you build a "sees everything" role — a manager, a reporting account — and it is the exact analog of &lt;code&gt;EXEMPT REDACTION POLICY&lt;/code&gt;. It is also a master key: grant it to as few accounts as possible and audit who holds it, because one such account defeats every row policy you've written.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- MGR sees every row, in every region, on every VPD-protected table:&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;EXEMPT&lt;/span&gt; &lt;span class="k"&gt;ACCESS&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%3AeNptkl1PwjAUhv_Km96AieBHvJH4ERwlmICMbRGJM6asB5kZLWm3EAL8d7tNojFen5znfc7b7liiJbEO2CLTm2QpTI6oFytg8hqzSUFmi4XRKwTcf-fdMLqZm7O7kA-5F6HdbqMfjEfQRpKxMXtDq3UHfxezZ7-Htc7SZAutMA56PAg71W5qkS8JlqxN3Yi_8JEf3cfsUIb6JWAfM6URF5fnF1ewxfyTkhzNo8BJzPboOzm_xi8KleQlyRTKVgmG8sIoCxj6KAe3aJSLDedXZvQrya4jjI1IMoJYr0nJWmttSKaJyKkiuQOnAx5wNP-gTr5Z3YoVONaDsIRczB3PJkIpkpg-RoN_oO6wi-tLV0u2RQmD0RuLWvobe6xhS_bYQ90Tup7HwxD-ePjozXCGcBaWfXjO4En_BEFISfJ3nMiyP0HsFGxFZiVS6d5_x5zpqvoJkhaiyHJ2OHwBIBWqPA" 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%3AeNptkl1PwjAUhv_Km96AieBHvJH4ERwlmICMbRGJM6asB5kZLWm3EAL8d7tNojFen5znfc7b7liiJbEO2CLTm2QpTI6oFytg8hqzSUFmi4XRKwTcf-fdMLqZm7O7kA-5F6HdbqMfjEfQRpKxMXtDq3UHfxezZ7-Htc7SZAutMA56PAg71W5qkS8JlqxN3Yi_8JEf3cfsUIb6JWAfM6URF5fnF1ewxfyTkhzNo8BJzPboOzm_xi8KleQlyRTKVgmG8sIoCxj6KAe3aJSLDedXZvQrya4jjI1IMoJYr0nJWmttSKaJyKkiuQOnAx5wNP-gTr5Z3YoVONaDsIRczB3PJkIpkpg-RoN_oO6wi-tLV0u2RQmD0RuLWvobe6xhS_bYQ90Tup7HwxD-ePjozXCGcBaWfXjO4En_BEFISfJ3nMiyP0HsFGxFZiVS6d5_x5zpqvoJkhaiyHJ2OHwBIBWqPA" alt="Where the row filter is applied — and where it isn't. For a subject session Oracle runs th" width="508" height="880"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where the row filter is applied — and where it isn't. For a subject session Oracle runs the policy function, gets a predicate, and appends it to the query against the base table, so only the allowed rows come back. An exempt session (EXEMPT ACCESS POLICY, or SYS) skips the function entirely and sees the whole table. There is no view in the middle to route around.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Put a policy like the one above on an &lt;code&gt;ORDERS&lt;/code&gt; table of known shape — 3,000 rows, deterministically split &lt;strong&gt;1,800 EAST / 1,200 WEST&lt;/strong&gt; — then read it back as three users: &lt;code&gt;REP_EAST&lt;/code&gt;, &lt;code&gt;REP_WEST&lt;/code&gt;, and a &lt;code&gt;MGR&lt;/code&gt; who holds &lt;code&gt;EXEMPT ACCESS POLICY&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;-- each of these is the SAME statement; only the connected user differs&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;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;vpdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;user&lt;/th&gt;
&lt;th&gt;rows returned&lt;/th&gt;
&lt;th&gt;regions seen&lt;/th&gt;
&lt;th&gt;&lt;code&gt;SUM(amount)&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;WHERE id = 3&lt;/code&gt; (a WEST row)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REP_EAST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,800&lt;/td&gt;
&lt;td&gt;EAST only&lt;/td&gt;
&lt;td&gt;180,000&lt;/td&gt;
&lt;td&gt;0 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REP_WEST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,200&lt;/td&gt;
&lt;td&gt;WEST only&lt;/td&gt;
&lt;td&gt;120,000&lt;/td&gt;
&lt;td&gt;1 row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MGR&lt;/code&gt; (exempt)&lt;/td&gt;
&lt;td&gt;3,000&lt;/td&gt;
&lt;td&gt;EAST + WEST&lt;/td&gt;
&lt;td&gt;300,000&lt;/td&gt;
&lt;td&gt;1 row&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that table slowly, because every column is a separate proof. The &lt;strong&gt;row counts&lt;/strong&gt; show each rep is confined to their region. The &lt;strong&gt;&lt;code&gt;SUM(amount)&lt;/code&gt;&lt;/strong&gt; shows the predicate is applied to &lt;em&gt;aggregates&lt;/em&gt; too — &lt;code&gt;REP_EAST&lt;/code&gt; can't even learn the company-wide total, only their own 180,000. And the &lt;strong&gt;&lt;code&gt;WHERE id = 3&lt;/code&gt;&lt;/strong&gt; probe is the one that kills the "I'll just ask for the row directly" idea: id 3 is a WEST order, and when &lt;code&gt;REP_EAST&lt;/code&gt; asks for it by primary key, Oracle appends &lt;code&gt;AND region = 'EAST'&lt;/code&gt; and returns nothing. There is no query shape that widens the result. &lt;code&gt;MGR&lt;/code&gt;, holding the exempt privilege, sees all 3,000 — the intended bypass, working as designed.&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/vpd" rel="noopener noreferrer"&gt;VPD lab&lt;/a&gt; stands up an Oracle Database Free container, builds &lt;code&gt;ORDERS&lt;/code&gt; (3,000 rows, 1,800 EAST / 1,200 WEST) behind a &lt;code&gt;DBMS_RLS&lt;/code&gt; policy, and reads it back as &lt;code&gt;REP_EAST&lt;/code&gt;, &lt;code&gt;REP_WEST&lt;/code&gt;, and an &lt;code&gt;EXEMPT&lt;/code&gt; &lt;code&gt;MGR&lt;/code&gt;. It &lt;strong&gt;asserts&lt;/strong&gt; each rep sees only its region on the row count, on &lt;code&gt;SUM(amount)&lt;/code&gt;, &lt;strong&gt;and&lt;/strong&gt; on a targeted by-id lookup, and that the exempt manager sees all 3,000. If any reader ever sees a row it shouldn't — or the aggregates aren't filtered — the run fails. It's proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Don't forget the other three verbs
&lt;/h2&gt;

&lt;p&gt;The lab policy covers &lt;code&gt;SELECT&lt;/code&gt;, which is where most people start. But row-level security that only filters reads has a hole: what stops &lt;code&gt;REP_EAST&lt;/code&gt; from &lt;strong&gt;inserting&lt;/strong&gt; a WEST order, or &lt;strong&gt;updating&lt;/strong&gt; an EAST order to move it to WEST? By default, nothing. VPD policies apply to the statement types you name, and for writes you almost always want the full set:&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;DBMS_RLS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ADD_POLICY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;object_schema&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'VPDOWN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_name&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;policy_name&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ORDERS_REGION_POLICY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;function_schema&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'VPDOWN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy_function&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ORDERS_RLS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;statement_types&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SELECT,INSERT,UPDATE,DELETE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;update_check&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="c1"&gt;-- reject writes that would land a row outside the session's slice&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;update_check =&amp;gt; TRUE&lt;/code&gt; is the important flag: without it, the predicate filters which rows an &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt; can &lt;em&gt;see&lt;/em&gt;, but an &lt;code&gt;INSERT&lt;/code&gt; (or an &lt;code&gt;UPDATE&lt;/code&gt; that changes &lt;code&gt;region&lt;/code&gt;) could still write a row the session wouldn't be allowed to read back. With it on, Oracle re-checks the new row against the predicate and raises an error if it falls outside — so a rep can't stash a row in another region.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Using a view (or the application) as the security boundary.&lt;/strong&gt; A view filters only if everyone goes through it; grant the base table for one report and it's over. Application-tier filtering is worse — every new client, script, or BI tool is a fresh way around it. VPD moves the rule into the table so there's one place, enforced for every path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filtering reads but not writes.&lt;/strong&gt; A &lt;code&gt;SELECT&lt;/code&gt;-only policy lets a user insert or move rows outside their slice. Add &lt;code&gt;INSERT,UPDATE,DELETE&lt;/code&gt; with &lt;code&gt;update_check =&amp;gt; TRUE&lt;/code&gt; when the rows are meant to stay put.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keying the predicate off something spoofable.&lt;/strong&gt; A &lt;code&gt;CLIENT_IDENTIFIER&lt;/code&gt; or context value the application sets for convenience is a lock the user can pick by setting the same value. Key off &lt;code&gt;SESSION_USER&lt;/code&gt;, an enabled role, or an application context populated by a trusted package the user can't call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting the exempt paths.&lt;/strong&gt; &lt;code&gt;EXEMPT ACCESS POLICY&lt;/code&gt; holders and &lt;code&gt;SYS&lt;/code&gt; see everything; a full Data Pump export run by such an account carries every row; the object owner's own access can bypass the policy. Audit who is exempt and treat privileged exports as sensitive — the same discipline as &lt;code&gt;EXEMPT REDACTION POLICY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A heavy or non-deterministic policy function.&lt;/strong&gt; The function runs as part of query parsing/execution, so a slow lookup or a function that returns different predicates unpredictably hurts performance and plan-sharing. Keep it cheap, and use the right &lt;strong&gt;policy type&lt;/strong&gt; (&lt;code&gt;STATIC&lt;/code&gt;, &lt;code&gt;SHARED_STATIC&lt;/code&gt;, &lt;code&gt;CONTEXT_SENSITIVE&lt;/code&gt;) so Oracle caches the predicate instead of re-running the function on every call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting VPD to hide a &lt;em&gt;column&lt;/em&gt;.&lt;/strong&gt; VPD filters rows. If you need to blank out a column's value for some users, that's &lt;a href="https://uptimearchitect.com/blog/oracle-data-redaction-mask-at-read-time/" rel="noopener noreferrer"&gt;Data Redaction&lt;/a&gt; (or VPD's column-sensitive mode with &lt;code&gt;sec_relevant_cols&lt;/code&gt;, which applies the row predicate only when a sensitive column is selected). Match the tool to whether you're hiding a row or a value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reaching for VPD when you need labels or a full model.&lt;/strong&gt; For classification-driven access (secret/confidential/public) or complex, centrally-managed policies across many tables, Oracle Label Security and Real Application Security exist and are built for that. VPD is the lightweight, write-your-own-predicate option — perfect for "each tenant sees their own rows," heavier machinery for more.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What is Oracle Virtual Private Database (VPD)?
&lt;/h3&gt;

&lt;p&gt;Virtual Private Database, also called Fine-Grained Access Control and implemented through the DBMS_RLS package, is an Oracle feature that enforces row-level (and optionally column-sensitive) security by attaching a policy to a table, view, or synonym. You write a policy function that returns a SQL predicate, and Oracle transparently appends that predicate to every query the session runs against the object, so each session sees only the rows the predicate allows. Because the rule lives on the base object rather than in a view or the application, it is enforced for every access path — ad-hoc SQL, reports, exports, and joins alike. It requires no changes to the application or the SQL text.&lt;/p&gt;

&lt;h3&gt;
  
  
  How is VPD different from using a view to hide rows?
&lt;/h3&gt;

&lt;p&gt;A view is a separate object: it filters rows only for sessions that query the view, so the protection disappears the moment someone is granted and queries the underlying base table (a report, an export, an ad-hoc query). VPD attaches the predicate to the base table itself, so every statement against that table — through any path — carries the filter. There is nothing to route around. Views are fine for convenience and shaping data; VPD is the right tool when the row restriction is a security boundary that must hold no matter how the table is reached.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a VPD policy function and what must it return?
&lt;/h3&gt;

&lt;p&gt;A VPD policy function is a PL/SQL function that takes two arguments — the schema and object name — and returns a VARCHAR2 containing a SQL predicate (the text that would go after WHERE), or an empty/NULL string to add no restriction. Oracle calls it for each session that queries the protected object and appends the returned predicate to the statement. The function typically bases the predicate on an unspoofable session fact such as SYS_CONTEXT('USERENV','SESSION_USER') or an application context set at logon. It should be lightweight and, ideally, deterministic for a given session so Oracle can cache the predicate using an appropriate policy type (STATIC, SHARED_STATIC, or CONTEXT_SENSITIVE).&lt;/p&gt;

&lt;h3&gt;
  
  
  Who is exempt from VPD policies?
&lt;/h3&gt;

&lt;p&gt;The SYS user (and SYSDBA connections) are always exempt and see the unfiltered table. In addition, any user granted the EXEMPT ACCESS POLICY system privilege bypasses every VPD policy in the database, regardless of the policy functions. That privilege is how you build a role that sees all rows — for example a manager or a reporting account — and it is the exact analog of EXEMPT REDACTION POLICY for Data Redaction. Because a single exempt account defeats every row policy, grant it to as few users as possible and audit who holds it. Note also that a full Data Pump export run by a privileged or exempt account carries every row, so treat such exports as sensitive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does VPD apply to INSERT, UPDATE, and DELETE, or only SELECT?
&lt;/h3&gt;

&lt;p&gt;VPD applies to whatever statement types you name in DBMS_RLS.ADD_POLICY. A SELECT-only policy filters reads but does nothing to stop a user inserting a row outside their slice or updating a row to move it there. For a complete boundary, add INSERT, UPDATE, and DELETE to statement_types, and set update_check =&amp;gt; TRUE. The update_check flag makes Oracle re-evaluate the predicate against the new or changed row and reject the write if the row would fall outside what the session is allowed to see, so a user cannot write rows they could not read back. For INSERT and UPDATE you can also supply separate policy functions if the write rule differs from the read rule.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between VPD, Data Redaction, and Oracle Label Security?
&lt;/h3&gt;

&lt;p&gt;They protect different things. VPD (Virtual Private Database) filters ROWS: a per-session predicate decides which rows a query returns, and the row is simply absent for users who should not see it. Data Redaction masks COLUMN VALUES at read time: the row is still returned, but a protected column comes back masked, partial, or random — the data on disk is unchanged. Oracle Label Security (OLS) is a packaged, classification-driven form of row-level security built on VPD, where each row carries a sensitivity label and access is decided by comparing the label to the user's clearance, managed centrally rather than by hand-written predicates. A mature design can combine them: VPD or OLS to control which rows, redaction to mask sensitive columns within the rows a user can see, and TDE to encrypt the whole thing at rest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does VPD hurt query performance?
&lt;/h3&gt;

&lt;p&gt;VPD adds a predicate to the query and calls the policy function, so the cost has two parts: running the function and executing the extra filter. The function overhead is controlled by the policy type — STATIC and SHARED_STATIC cache the predicate and run the function once, CONTEXT_SENSITIVE re-evaluates only when a relevant context changes, and DYNAMIC (the default) runs it on every parse/execute — so choosing the right type for how often the predicate actually changes matters. The predicate itself is just SQL: if it is selective and supported by an index (for example region = 'EAST' with an index on region), it can even reduce work; if it is written poorly it adds a filter like any other WHERE clause. Keep the function cheap and deterministic, index the columns the predicate uses, and pick the policy type that matches your predicate's volatility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is VPD available in Oracle Database Free and Standard Edition?
&lt;/h3&gt;

&lt;p&gt;VPD is a feature of Oracle Database Enterprise Edition, and it is included with EE rather than being a separately licensed option (unlike Advanced Security, which covers TDE and Data Redaction). It is available in Oracle Database Free, which is built on the Enterprise Edition feature set, which is why you can build and run the companion lab on the zero-license Free image with nothing but Docker. It is not part of Standard Edition. As always, confirm your specific edition and entitlement before relying on it in production, since licensing depends on your platform and can change.&lt;/p&gt;

&lt;p&gt;Row-level security is one layer of the same data-protection discipline as the rest: &lt;a href="https://uptimearchitect.com/blog/oracle-data-redaction-mask-at-read-time/" rel="noopener noreferrer"&gt;Data Redaction&lt;/a&gt; masks the values within the rows a user can see, &lt;a href="https://uptimearchitect.com/blog/oracle-transparent-data-encryption-tde/" rel="noopener noreferrer"&gt;TDE&lt;/a&gt; makes the files unreadable when they're stolen, &lt;a href="https://uptimearchitect.com/blog/oracle-unified-auditing/" rel="noopener noreferrer"&gt;unified auditing&lt;/a&gt; records who looked, and the &lt;a href="https://uptimearchitect.com/blog/oracle-database-hardening-checklist/" rel="noopener noreferrer"&gt;hardening checklist&lt;/a&gt; and least-privilege grants decide who can connect at all. VPD decides which &lt;em&gt;rows&lt;/em&gt; each session is even allowed to see — and, unlike a view, it decides it on the base table so there's no way around it. Put the rule where the data lives, key it off something the user can't forge, remember the write path, and then prove it the way that ends the argument: with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/vpd" rel="noopener noreferrer"&gt;VPD lab&lt;/a&gt;, where three users query one table and each sees only what they should.&lt;/p&gt;




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

</description>
      <category>oracle</category>
      <category>database</category>
      <category>security</category>
      <category>vpd</category>
    </item>
    <item>
      <title>Oracle Data Pump, Done Right: A Migration You Can Prove</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sun, 13 Sep 2026 14:51:13 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-data-pump-done-right-a-migration-you-can-prove-43oi</link>
      <guid>https://dev.to/uptimearchitect/oracle-data-pump-done-right-a-migration-you-can-prove-43oi</guid>
      <description>&lt;p&gt;&lt;code&gt;expdp full=y&lt;/code&gt; and hope is not a migration plan. It's how you find out at 3 a.m. that the dump ran out of space, or the import invented a half-populated schema, or the "quick refresh" locked the source for an hour because nobody set a consistency point.&lt;/p&gt;

&lt;p&gt;Data Pump is the workhorse that moves Oracle data — schema refreshes, database migrations, the lift into the cloud, the copy into a test environment. Almost every Oracle shop runs &lt;code&gt;expdp&lt;/code&gt; and &lt;code&gt;impdp&lt;/code&gt;; far fewer run them &lt;em&gt;deliberately&lt;/em&gt;, with the handful of options that turn "it seemed to work" into "the row counts match and I can prove it." This post is the architecture worth understanding, the flags that actually matter, the quoting trap that eats an afternoon, and — the part that ends the argument — a lab that exports a schema, drops it, imports it back, and asserts nothing was lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Data Pump actually is
&lt;/h2&gt;

&lt;p&gt;The first thing to internalize: &lt;strong&gt;Data Pump runs inside the database server, not on your client.&lt;/strong&gt; When you launch &lt;code&gt;expdp&lt;/code&gt;, the client just starts and monitors a job; the actual reading and writing is done by server processes, and the dump file is written to a path &lt;em&gt;on the database server&lt;/em&gt;, referenced through a &lt;strong&gt;&lt;code&gt;DIRECTORY&lt;/code&gt; object&lt;/strong&gt; — never a client-side path. This is the big break from the legacy &lt;code&gt;exp&lt;/code&gt;/&lt;code&gt;imp&lt;/code&gt; tools (which streamed through the client and are deprecated for good reason): Data Pump is faster, parallelizable, restartable, and runs where the data is.&lt;/p&gt;

&lt;p&gt;That architecture has direct consequences you plan around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need a &lt;code&gt;DIRECTORY&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;CREATE DIRECTORY dp_dir AS '/path/on/the/server'&lt;/code&gt; and grant read/write on it. The dump lives there, not on your laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It has modes.&lt;/strong&gt; &lt;code&gt;FULL=Y&lt;/code&gt; (whole database), &lt;code&gt;SCHEMAS=&lt;/code&gt; (one or more schemas — the common one), &lt;code&gt;TABLES=&lt;/code&gt;, and &lt;code&gt;TABLESPACES=&lt;/code&gt;. Pick the narrowest mode that covers what you're moving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can skip the dump file entirely.&lt;/strong&gt; With &lt;code&gt;NETWORK_LINK=&lt;/code&gt;, &lt;code&gt;impdp&lt;/code&gt; pulls straight from a source database over a database link — no dump file, no staging, no copying gigabytes twice. For a migration into a new database this is often the cleanest path.&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%2Fmermaid.ink%2Fimg%2Fpako%3AeNpd0U1Lw0AQBuC_8rInxabGohfRgibBln6FJCBipMTslgSzH-wmVmj6391s68Xb7GGfeWfmQEpJGbkH2TVyX1aFbrFMcgGkSfCek1R2umQwZcV48fCpr6dhnM42MfJu4t_c4m7k-z6uMPFdoeXe5OQDnjft2Y-iCmkwi1ZP6SNVppKqR7iKLTvUY8qVE2uBAuE8iYJsk7xBCrSVbcn0N9MWG8LYX86suTV7JJk1novyC7VoJU6RnNVIYxpmjE3SCYpW1-ofkROHILGx4u0p3jndPVVlIwXLSY_sZegRxsFys44cbQrO3IAjCLaHsM8zbVcFb4w_eR1lr5tksV3O1wtcCAnacYVd3bDLnGDsTQecjEA407yoqd3-gdiRubsDZbuia1pyPP4C3fuCPQ" 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%3AeNpd0U1Lw0AQBuC_8rInxabGohfRgibBln6FJCBipMTslgSzH-wmVmj6391s68Xb7GGfeWfmQEpJGbkH2TVyX1aFbrFMcgGkSfCek1R2umQwZcV48fCpr6dhnM42MfJu4t_c4m7k-z6uMPFdoeXe5OQDnjft2Y-iCmkwi1ZP6SNVppKqR7iKLTvUY8qVE2uBAuE8iYJsk7xBCrSVbcn0N9MWG8LYX86suTV7JJk1novyC7VoJU6RnNVIYxpmjE3SCYpW1-ofkROHILGx4u0p3jndPVVlIwXLSY_sZegRxsFys44cbQrO3IAjCLaHsM8zbVcFb4w_eR1lr5tksV3O1wtcCAnacYVd3bDLnGDsTQecjEA407yoqd3-gdiRubsDZbuia1pyPP4C3fuCPQ" alt="Data Pump is server-side: expdp writes the dump to a DIRECTORY on the database host, and impdp reads it back" width="1302" height="248"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Data Pump is server-side: expdp writes the dump to a DIRECTORY on the database host, and impdp reads it back — into the same schema (a round trip) or, with REMAP_SCHEMA, into a new one (the migration move). With NETWORK_LINK, impdp skips the dump file and pulls straight from the source over a database link.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The flags that actually matter
&lt;/h2&gt;

&lt;p&gt;Most of Data Pump's power is in a dozen parameters. These are the ones that decide whether a migration is clean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;REMAP_SCHEMA=source:target&lt;/code&gt;&lt;/strong&gt; — import the data into a &lt;em&gt;different&lt;/em&gt; schema than it came from. This is the heart of most migrations and refreshes: export &lt;code&gt;PROD_APP&lt;/code&gt;, import as &lt;code&gt;TEST_APP&lt;/code&gt;. Its siblings &lt;code&gt;REMAP_TABLESPACE&lt;/code&gt; and &lt;code&gt;REMAP_DATAFILE&lt;/code&gt; do the same for storage when the target's layout differs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PARALLEL=n&lt;/code&gt;&lt;/strong&gt; — run the job with &lt;em&gt;n&lt;/em&gt; worker processes. Essential for anything large, but with one catch people miss: give it multiple dump files with the &lt;strong&gt;&lt;code&gt;%U&lt;/code&gt;&lt;/strong&gt; wildcard (&lt;code&gt;DUMPFILE=exp_%U.dmp&lt;/code&gt;) so the workers aren't all fighting over one file. &lt;code&gt;PARALLEL=8&lt;/code&gt; against a single dumpfile is mostly waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;INCLUDE&lt;/code&gt; / &lt;code&gt;EXCLUDE&lt;/code&gt; / &lt;code&gt;QUERY&lt;/code&gt;&lt;/strong&gt; — move part of the data. &lt;code&gt;INCLUDE=TABLE:"IN ('ORDERS')"&lt;/code&gt; takes one table; &lt;code&gt;EXCLUDE=STATISTICS&lt;/code&gt; skips stats (regather them faster on the target); &lt;code&gt;QUERY&lt;/code&gt; exports only rows matching a predicate. These are also the parameters with the notorious quoting problem — more on that below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CONTENT=&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;DATA_ONLY&lt;/code&gt;, &lt;code&gt;METADATA_ONLY&lt;/code&gt;, or &lt;code&gt;ALL&lt;/code&gt;. Migrate structure first and data later, or refresh data into an existing schema without touching its objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TABLE_EXISTS_ACTION=&lt;/code&gt;&lt;/strong&gt; — what &lt;code&gt;impdp&lt;/code&gt; does when a table is already there: &lt;code&gt;SKIP&lt;/code&gt; (the surprising default — it silently leaves existing tables alone), &lt;code&gt;APPEND&lt;/code&gt;, &lt;code&gt;TRUNCATE&lt;/code&gt;, or &lt;code&gt;REPLACE&lt;/code&gt;. Getting this wrong is how a "refresh" ends up with yesterday's data quietly untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FLASHBACK_TIME&lt;/code&gt; / &lt;code&gt;FLASHBACK_SCN&lt;/code&gt;&lt;/strong&gt; — export a &lt;strong&gt;consistent&lt;/strong&gt; point in time. Without it, a long export of a busy database can capture different tables at different moments, and referential integrity in the dump is a coin flip. &lt;code&gt;FLASHBACK_TIME=SYSTIMESTAMP&lt;/code&gt; costs nothing and buys you a consistent snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ENCRYPTION&lt;/code&gt; / &lt;code&gt;ENCRYPTION_PASSWORD&lt;/code&gt;&lt;/strong&gt; — the dump file is plaintext data sitting on disk. If it contains anything sensitive, encrypt it, or you've just made an unprotected copy of your production data — the same "least-protected copy" problem that undoes &lt;a href="https://uptimearchitect.com/blog/oracle-transparent-data-encryption-tde/" rel="noopener noreferrer"&gt;encryption at rest&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;VERSION=&lt;/code&gt;&lt;/strong&gt; — writing a dump a &lt;em&gt;lower&lt;/em&gt; version can import (e.g., migrating down a release). Set it when the target is older than the source; forget it and the import simply refuses the file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal, deliberate schema export and remap looks like this:&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;# export one schema, consistent as of now, in parallel across 4 dump files&lt;/span&gt;
expdp system/&lt;span class="k"&gt;***&lt;/span&gt; &lt;span class="nv"&gt;DIRECTORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dp_dir &lt;span class="nv"&gt;SCHEMAS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nv"&gt;DUMPFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app_%U.dmp &lt;span class="nv"&gt;PARALLEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nv"&gt;FLASHBACK_TIME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SYSTIMESTAMP &lt;span class="nv"&gt;LOGFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;exp.log

&lt;span class="c"&gt;# import it into a DIFFERENT schema on the target&lt;/span&gt;
impdp system/&lt;span class="k"&gt;***&lt;/span&gt; &lt;span class="nv"&gt;DIRECTORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dp_dir &lt;span class="nv"&gt;DUMPFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app_%U.dmp &lt;span class="nv"&gt;PARALLEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nv"&gt;REMAP_SCHEMA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app:app_test &lt;span class="nv"&gt;TABLE_EXISTS_ACTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;REPLACE &lt;span class="nv"&gt;LOGFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;imp.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The quoting trap — use a parfile
&lt;/h2&gt;

&lt;p&gt;The single most common way a Data Pump command fails is the shell mangling a filter. This looks right and almost never works from a normal shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;expdp system/&lt;span class="k"&gt;***&lt;/span&gt; &lt;span class="nv"&gt;SCHEMAS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app &lt;span class="nv"&gt;INCLUDE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;TABLE:&lt;span class="s2"&gt;"IN ('ORDERS')"&lt;/span&gt; &lt;span class="nv"&gt;DIRECTORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dp_dir &lt;span class="nv"&gt;DUMPFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;x.dmp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The double quotes, single quotes, and parentheses get eaten or reinterpreted by the shell before Data Pump ever sees them. The fix that always works is a &lt;strong&gt;parameter file&lt;/strong&gt; — put the parameters in a text file, where no shell touches them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# filt.par
SCHEMAS=app
INCLUDE=TABLE:"IN ('ORDERS')"
DIRECTORY=dp_dir
DUMPFILE=orders_only.dmp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;expdp system/&lt;span class="k"&gt;***&lt;/span&gt; &lt;span class="nv"&gt;parfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;filt.par
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you take one operational habit from this post: &lt;strong&gt;any Data Pump job with an &lt;code&gt;INCLUDE&lt;/code&gt;, &lt;code&gt;EXCLUDE&lt;/code&gt;, or &lt;code&gt;QUERY&lt;/code&gt; goes in a parfile.&lt;/strong&gt; It's the difference between a repeatable command and a guessing game.&lt;/p&gt;

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

&lt;p&gt;The whole promise of a migration is that the data arrives intact. That's checkable. Build a schema of known size — 5,000 customers and 20,000 orders — export it, drop it, and import it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;after re-import: DPSHOP customers=5000  orders=20000
   -&amp;gt; lossless round trip: exact same 5,000 + 20,000 rows came back.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the actual migration move — import into a &lt;em&gt;new&lt;/em&gt; schema with &lt;code&gt;REMAP_SCHEMA&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;impdp ... REMAP_SCHEMA=dpshop:dpclone
   DPCLONE customers=5000  orders=20000
   -&amp;gt; same data, new schema name.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a selective export — &lt;code&gt;INCLUDE&lt;/code&gt; just one table, via a parfile — lands only that table in the target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DPONLY tables=[ORDERS]  orders=20000
   -&amp;gt; only ORDERS came across (no CUSTOMERS). INCLUDE filtered the export exactly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same rows, every time, with the counts asserted rather than eyeballed. That's a migration you can hand to a change-approval board.&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/data-pump" rel="noopener noreferrer"&gt;Data Pump lab&lt;/a&gt; stands up an Oracle Database Free container, builds the 5,000/20,000-row &lt;code&gt;DPSHOP&lt;/code&gt; schema, and runs four drills: &lt;code&gt;expdp&lt;/code&gt; the schema, drop it and &lt;code&gt;impdp&lt;/code&gt; it back (asserting the row counts match), &lt;code&gt;impdp&lt;/code&gt; with &lt;code&gt;REMAP_SCHEMA&lt;/code&gt; into a new schema, and a parfile-driven &lt;code&gt;INCLUDE&lt;/code&gt; export that asserts only the chosen table crosses over. If the round trip loses a row or the filter leaks a table, the run fails. It's 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;Command-line filters without a parfile.&lt;/strong&gt; &lt;code&gt;INCLUDE&lt;/code&gt;/&lt;code&gt;EXCLUDE&lt;/code&gt;/&lt;code&gt;QUERY&lt;/code&gt; typed at the shell get their quotes mangled. Put them in a parfile, always.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PARALLEL&lt;/code&gt; against one dump file.&lt;/strong&gt; The workers serialize on the single file. Use &lt;code&gt;DUMPFILE=name_%U.dmp&lt;/code&gt; so each worker writes its own, and match the dumpfile count to &lt;code&gt;PARALLEL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No consistency point on a busy source.&lt;/strong&gt; A multi-minute export without &lt;code&gt;FLASHBACK_TIME&lt;/code&gt; can capture tables at different SCNs, so foreign keys in the dump don't line up. Always set &lt;code&gt;FLASHBACK_TIME=SYSTIMESTAMP&lt;/code&gt; (or an &lt;code&gt;SCN&lt;/code&gt;) for anything transactional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting the default &lt;code&gt;TABLE_EXISTS_ACTION&lt;/code&gt;.&lt;/strong&gt; It's &lt;code&gt;SKIP&lt;/code&gt; — existing tables are left as-is, so a "data refresh" into a populated schema can silently do nothing. Choose &lt;code&gt;TRUNCATE&lt;/code&gt;, &lt;code&gt;APPEND&lt;/code&gt;, or &lt;code&gt;REPLACE&lt;/code&gt; on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A plaintext dump of sensitive data.&lt;/strong&gt; The &lt;code&gt;.dmp&lt;/code&gt; is readable data on disk and in whatever bucket you copy it to. Encrypt it (&lt;code&gt;ENCRYPTION_PASSWORD&lt;/code&gt;) if it holds anything you'd protect in the database, and delete it when the move is done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting the things that aren't rows.&lt;/strong&gt; Grants, stored code, and &lt;em&gt;statistics&lt;/em&gt; need to arrive too. Data Pump moves them by default, but people who script &lt;code&gt;CONTENT=DATA_ONLY&lt;/code&gt; migrations often forget to also move privileges and to regather or import stats — and then the target is "there" but slow and half-broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copying a dump when &lt;code&gt;NETWORK_LINK&lt;/code&gt; would do.&lt;/strong&gt; Exporting to a file, copying it to the target host, and importing is three steps and double the storage. For a straight database-to-database move, &lt;code&gt;impdp NETWORK_LINK=&lt;/code&gt; pulls directly and skips the file entirely.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What is Oracle Data Pump and how is it different from the old exp/imp?
&lt;/h3&gt;

&lt;p&gt;Data Pump (the expdp and impdp utilities, introduced in Oracle 10g) is Oracle's tool for exporting and importing data and metadata. The key difference from the legacy exp/imp tools is that Data Pump runs server-side: the client only launches and monitors the job while server processes do the work and read or write the dump file on the database host through a DIRECTORY object. This makes it substantially faster, parallelizable with the PARALLEL parameter, restartable after a failure, and able to remap schemas, tablespaces, and datafiles during import. The original exp/imp streamed through the client, cannot be parallelized, and are deprecated; new work should use Data Pump. Dump files from the two tools are not interchangeable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Data Pump need a DIRECTORY object?
&lt;/h3&gt;

&lt;p&gt;Because Data Pump reads and writes dump and log files on the database server, not on the client machine, it needs a server-side location it is allowed to use, and Oracle represents that as a DIRECTORY database object mapping a name to a filesystem path. You create it with CREATE DIRECTORY dp_dir AS '/path/on/server' and grant READ and WRITE on it to the user running the job. This indirection is a security feature: it means a user can only write dumps to paths a DBA has explicitly sanctioned, rather than anywhere on the server filesystem. On Autonomous Database and some cloud services you use a pre-provided directory (such as DATA_PUMP_DIR) or an object-store location instead of an arbitrary path.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I migrate a schema to a different name with Data Pump?
&lt;/h3&gt;

&lt;p&gt;Use REMAP_SCHEMA=source_schema:target_schema on the impdp command. Data Pump imports all of the source schema's objects and data into the target schema, creating the target user if the dump was a schema-mode export (which includes the user definition). For example, impdp system/*** DIRECTORY=dp_dir DUMPFILE=app.dmp REMAP_SCHEMA=app:app_test loads the exported APP schema into APP_TEST. You can combine it with REMAP_TABLESPACE to redirect storage when the target database has a different tablespace layout, and with TABLE_EXISTS_ACTION to control what happens if objects already exist. This schema remap is the core of most refreshes and migrations between environments.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I export or import only some tables or rows?
&lt;/h3&gt;

&lt;p&gt;Use INCLUDE, EXCLUDE, and QUERY. INCLUDE=TABLE:"IN ('ORDERS','CUSTOMERS')" exports only those tables; EXCLUDE=TABLE:"='AUDIT_LOG'" exports everything except one; EXCLUDE=STATISTICS skips optimizer statistics; and QUERY="WHERE created &amp;gt; DATE '2026-01-01'" exports only matching rows. The crucial practical point is quoting: these filters contain quotes and parentheses that a shell will mangle, so put them in a parameter file (parfile) and run expdp parfile=my.par rather than typing them on the command line. A parfile passes the filter to Data Pump verbatim, which is why it is the reliable way to use any of these parameters.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I make a Data Pump export consistent?
&lt;/h3&gt;

&lt;p&gt;By default a Data Pump export is not guaranteed to be consistent across tables: on a busy database a long-running export can read different tables at different points in time, so foreign-key relationships in the dump may not line up. To get a single consistent snapshot, set FLASHBACK_TIME=SYSTIMESTAMP (or a specific timestamp) or FLASHBACK_SCN= on the expdp job, which makes every table read as of the same system change number using undo, the same mechanism behind Flashback Query. This is essential for any transactional system and effectively free, so it should be a default habit for production exports. The database must have enough undo retention to cover the duration of the export.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I speed up a large Data Pump job?
&lt;/h3&gt;

&lt;p&gt;Use PARALLEL=n to run the job with multiple worker processes, and give it multiple dump files so the workers do not serialize on one file, using the %U wildcard in the dumpfile name (DUMPFILE=exp_%U.dmp), ideally with the dumpfile count at least equal to the parallelism. On import you can also EXCLUDE=STATISTICS and regather stats afterward, which is often faster than importing them. For a database-to-database migration, NETWORK_LINK lets impdp pull directly from the source over a database link, avoiding the write-copy-read cost of a dump file entirely. Finally, size the DIRECTORY location and undo appropriately, and put the dump on fast storage; Data Pump is frequently I/O bound.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I move data directly between databases without a dump file?
&lt;/h3&gt;

&lt;p&gt;Yes, with NETWORK_LINK on impdp. You create a database link from the target to the source, then run impdp with NETWORK_LINK= and the usual SCHEMAS or FULL and REMAP parameters, and Data Pump transfers the objects and data straight over the link with no dump file written or copied. This is ideal for migrations where staging a dump would mean copying large files between hosts, and it composes with REMAP_SCHEMA and the filter parameters. The trade-offs are that it holds a connection to the source for the duration and that some data types and options behave slightly differently over a link than through a file, so test the specific objects you are moving.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Data Pump require a separate license?
&lt;/h3&gt;

&lt;p&gt;No. Data Pump is a feature of Oracle Database at no additional license cost and is available in all editions, including Oracle Database Free, which is what makes it straightforward to practice with in a local container. The related but separate features you might layer on can have their own licensing: Advanced Security governs dump-file encryption with certain options (though ENCRYPTION_PASSWORD-based encryption is broadly available), and transportable tablespaces and some parallelism characteristics differ by edition. The core expdp/impdp workflow, including schema mode, remap, filters, network mode, and consistency, is available everywhere, so the techniques in this post apply from Free up to Exadata.&lt;/p&gt;

&lt;p&gt;Data Pump is the connective tissue of the cloud-migration story: once you've chosen &lt;a href="https://uptimearchitect.com/blog/oracle-cloud-migration-methods/" rel="noopener noreferrer"&gt;which migration method fits&lt;/a&gt; and &lt;a href="https://uptimearchitect.com/blog/oracle-database-at-azure-vs-oci/" rel="noopener noreferrer"&gt;where the database should live&lt;/a&gt;, Data Pump is usually how the data actually moves — into &lt;a href="https://uptimearchitect.com/blog/oracle-autonomous-database-oci-always-free/" rel="noopener noreferrer"&gt;Autonomous&lt;/a&gt; or a new home. Do it deliberately: the right mode, a consistency point, a parfile for every filter, parallelism with real dump files, and encryption on anything sensitive. Then prove the move the way that ends the argument — with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/data-pump" rel="noopener noreferrer"&gt;Data Pump lab&lt;/a&gt;, where a schema goes out, comes back, and the row counts still match to the last row.&lt;/p&gt;




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

</description>
      <category>oracle</category>
      <category>datapump</category>
      <category>expdpimpdp</category>
      <category>oraclemigration</category>
    </item>
    <item>
      <title>Oracle Partition Pruning: Read One Partition, Not the Whole Table</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:30:01 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-partition-pruning-read-one-partition-not-the-whole-table-2615</link>
      <guid>https://dev.to/uptimearchitect/oracle-partition-pruning-read-one-partition-not-the-whole-table-2615</guid>
      <description>&lt;p&gt;Someone partitions the big table, the migration ticket gets closed, and everyone moves on believing the queries are now fast. Then a report that should read one month of data reads &lt;em&gt;all&lt;/em&gt; of it, the plan says &lt;code&gt;PARTITION RANGE ALL&lt;/code&gt;, and the partitioning that was supposed to help is just extra objects to manage.&lt;/p&gt;

&lt;p&gt;Partitioning doesn't make queries fast. &lt;strong&gt;Partition pruning&lt;/strong&gt; does — and pruning is something the optimizer has to be &lt;em&gt;allowed&lt;/em&gt; to do. It's the one payoff that justifies the whole feature for performance work: when a query only needs June, Oracle reads the June partition and skips the other twenty-three. Get it and a report drops from scanning the table to scanning a slice of it. Lose it — usually to one small mistake in the &lt;code&gt;WHERE&lt;/code&gt; clause — and you've paid for partitioning and gotten none of the speed. This post is what pruning is, the mistake that quietly kills it, how to read it in the plan, and a lab that watches the same query read one partition or all of them depending on a single function call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pruning actually is
&lt;/h2&gt;

&lt;p&gt;A partitioned table is one logical table stored as several physical segments — say, &lt;code&gt;ORDERS&lt;/code&gt; split into one partition per month. &lt;strong&gt;Partition pruning&lt;/strong&gt; is the optimizer working out, at parse or run time, which of those segments a query could possibly need, and reading only those. If a query filters on the partition key in a way Oracle can map to partition boundaries, it touches the matching partitions and ignores the rest.&lt;/p&gt;

&lt;p&gt;That's the entire performance case for range partitioning. A query for one month out of two years reads 1/24th of the data. A &lt;code&gt;DELETE&lt;/code&gt; of last quarter's rows becomes a partition drop. A full scan that was unavoidable becomes a scan of one segment. None of it depends on an index — pruning happens &lt;em&gt;before&lt;/em&gt; access method selection, at the level of "which segments are even in play."&lt;/p&gt;

&lt;p&gt;The plan tells you whether it happened, in two columns most people never look at: &lt;strong&gt;Pstart&lt;/strong&gt; and &lt;strong&gt;Pstop&lt;/strong&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%2Fmermaid.ink%2Fimg%2Fpako%3AeNptkVFLwzAUhf_KJS9VWLErOkG2QbFlm2zdrBURKyNrbl2xTUaaKmPbfzcNo5toHhJuODnnO2RHUsGQ3AHJCvGdrqlUEPsJB3jsviXkZRxEAQjJUC4ZVQjDAfheHIDlOu6N7fRsp2v1V_Jq6IX-ua7_S3bbyBLyDrY93Jd0U4Gk_ANBCdjoxFzlgsNK1JxVe1hEz2GgsxdeFE_iyTyEyAtHATxNwtE0MGESKavgoeYIghdb7WyI3ZY4ni_vx150cSLqWK962bOZdWksBm2FFiyreWpI1jnDCtQa4RO3e_Cm039w9O0ZC36h3J7KHIlMlca7YdUeeocm9Y9QmxlZhJVqdNToriGp9dGD9t1x9jE1swGgRQFa2lpW2pN0gJQoS5oz_bc7oruU5pcZZrQuFDkcfgBMtplA" 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%3AeNptkVFLwzAUhf_KJS9VWLErOkG2QbFlm2zdrBURKyNrbl2xTUaaKmPbfzcNo5toHhJuODnnO2RHUsGQ3AHJCvGdrqlUEPsJB3jsviXkZRxEAQjJUC4ZVQjDAfheHIDlOu6N7fRsp2v1V_Jq6IX-ua7_S3bbyBLyDrY93Jd0U4Gk_ANBCdjoxFzlgsNK1JxVe1hEz2GgsxdeFE_iyTyEyAtHATxNwtE0MGESKavgoeYIghdb7WyI3ZY4ni_vx150cSLqWK962bOZdWksBm2FFiyreWpI1jnDCtQa4RO3e_Cm039w9O0ZC36h3J7KHIlMlca7YdUeeocm9Y9QmxlZhJVqdNToriGp9dGD9t1x9jE1swGgRQFa2lpW2pN0gJQoS5oz_bc7oruU5pcZZrQuFDkcfgBMtplA" alt="The same query, two WHERE clauses. A range predicate on the partition key lets the optimiz" width="599" height="494"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same query, two WHERE clauses. A range predicate on the partition key lets the optimizer map the filter to partition boundaries and read only June. Wrap that same key in a function and the optimizer can no longer see the boundaries, so it falls back to reading every partition — for the identical rows.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake that silently defeats it
&lt;/h2&gt;

&lt;p&gt;Here is the trap, and it's the reason a partitioned table can still scan itself end to end. &lt;strong&gt;The optimizer can only prune when it can see the raw partition key in the predicate.&lt;/strong&gt; The moment you wrap that key in a function, the boundaries disappear and pruning is off:&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;-- PRUNES: the optimizer maps the range straight to partition boundaries&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2025-06-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;order_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2025-07-01'&lt;/span&gt;

&lt;span class="c1"&gt;-- DOES NOT PRUNE: a function on the key hides the boundaries -&amp;gt; every partition is scanned&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;TO_CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYY-MM'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2025-06'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2025-06-15'&lt;/span&gt;          &lt;span class="c1"&gt;-- same problem, TRUNC hides the key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of those return the right rows. The &lt;code&gt;TO_CHAR&lt;/code&gt; version just does it by reading the entire table and throwing away 95% of what it read. It's an easy mistake precisely because it looks &lt;em&gt;tidier&lt;/em&gt; — &lt;code&gt;TO_CHAR(...) = '2025-06'&lt;/code&gt; reads like exactly what you mean. But the optimizer doesn't evaluate the function against partition metadata; it evaluates it against every row, which means it has to &lt;em&gt;fetch&lt;/em&gt; every row first.&lt;/p&gt;

&lt;p&gt;The fix is always the same shape: express the filter as a &lt;strong&gt;range on the bare column&lt;/strong&gt; — &lt;code&gt;&amp;gt;= start AND &amp;lt; end&lt;/code&gt; — so the boundaries stay visible. Same logic for &lt;code&gt;TRUNC&lt;/code&gt;, &lt;code&gt;TO_CHAR&lt;/code&gt;, &lt;code&gt;NVL&lt;/code&gt;, arithmetic, an implicit type conversion because you compared a &lt;code&gt;DATE&lt;/code&gt; column to a string — any of them can turn pruning off. When a partitioned table is scanning more than it should, a function on the partition key is the first thing to look for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static pruning vs dynamic pruning
&lt;/h2&gt;

&lt;p&gt;Not all pruning looks the same in the plan, and the difference is worth knowing so you don't misread a healthy plan as a broken one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static pruning&lt;/strong&gt; — the optimizer knows the partitions at parse time, because the predicate uses literals or is otherwise resolvable up front. &lt;code&gt;Pstart&lt;/code&gt;/&lt;code&gt;Pstop&lt;/code&gt; show actual partition numbers, e.g. &lt;code&gt;19 / 19&lt;/code&gt;. This is the cleanest case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic pruning&lt;/strong&gt; — the partitions can't be known until run time, typically because the predicate uses a &lt;strong&gt;bind variable&lt;/strong&gt; (&lt;code&gt;WHERE order_date &amp;gt;= :d&lt;/code&gt;). The plan shows &lt;code&gt;Pstart&lt;/code&gt;/&lt;code&gt;Pstop&lt;/code&gt; as &lt;strong&gt;&lt;code&gt;KEY&lt;/code&gt;&lt;/strong&gt;, which is &lt;em&gt;not&lt;/em&gt; a problem — it means "pruning happens, but the specific partitions are decided at execution." Bind variables are still the right default for reuse; &lt;code&gt;KEY&lt;/code&gt; is pruning working, just later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure you're hunting is neither of those. It's &lt;code&gt;Pstart = 1&lt;/code&gt; and &lt;code&gt;Pstop&lt;/code&gt; = the last partition — the plan operation &lt;code&gt;PARTITION RANGE ALL&lt;/code&gt;. That's the optimizer telling you it gave up and read everything.&lt;/p&gt;

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

&lt;p&gt;Put 1.2 million orders into a table with one partition per month — twenty-four monthly partitions across 2024 and 2025 — and run the same question ("how many orders in June 2025?") two ways. First the range predicate, then the function. Here are the real plans, straight from &lt;code&gt;DBMS_XPLAN&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- WHERE order_date &amp;gt;= DATE '2025-06-01' AND order_date &amp;lt; DATE '2025-07-01' ---
| Id  | Operation       | Name   | Pstart| Pstop |
|   2 |   PARTITION RANGE SINGLE|    |    19 |    19 |
|   3 |    TABLE ACCESS FULL    | ORDERS |    19 |    19 |

--- WHERE TO_CHAR(order_date,'YYYY-MM') = '2025-06' ---
| Id  | Operation        | Name   | Pstart| Pstop  |
|   2 |   PARTITION RANGE ALL|        |     1 |1048575 |
|   3 |    TABLE ACCESS FULL | ORDERS |     1 |1048575 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both queries return &lt;strong&gt;the same 50,000 rows&lt;/strong&gt;. The first reads &lt;strong&gt;one partition&lt;/strong&gt; (&lt;code&gt;PARTITION RANGE SINGLE&lt;/code&gt;, Pstart = Pstop = 19) and burns &lt;strong&gt;1,725 buffer gets&lt;/strong&gt;. The second reads &lt;strong&gt;all of them&lt;/strong&gt; (&lt;code&gt;PARTITION RANGE ALL&lt;/code&gt;) and burns &lt;strong&gt;41,448&lt;/strong&gt; — about &lt;strong&gt;24× more work for the identical answer.&lt;/strong&gt; That factor is not a coincidence: it's one partition versus twenty-four, which is exactly what pruning bought.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The odd-looking &lt;code&gt;Pstop = 1048575&lt;/code&gt; in the second plan is just Oracle's sentinel for "the maximum possible partition" on an interval-partitioned table — it means &lt;em&gt;all&lt;/em&gt; partitions, not that a million of them exist. &lt;code&gt;PARTITION RANGE ALL&lt;/code&gt; is the phrase that matters.&lt;/p&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/partition-pruning" rel="noopener noreferrer"&gt;partition pruning lab&lt;/a&gt; stands up an Oracle Database Free container, builds the 1.2M-row monthly-partitioned table, and runs both queries with &lt;code&gt;GATHER_PLAN_STATISTICS&lt;/code&gt;. It &lt;strong&gt;asserts&lt;/strong&gt; the range predicate gets &lt;code&gt;PARTITION RANGE SINGLE&lt;/code&gt; on a single partition, the function predicate gets &lt;code&gt;PARTITION RANGE ALL&lt;/code&gt;, both return the same 50,000 rows, and the pruned query reads at least 5× fewer buffers (it reads ~24× fewer). If pruning stops working, the run fails. It's 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;A function on the partition key.&lt;/strong&gt; The headline mistake: &lt;code&gt;TO_CHAR&lt;/code&gt;, &lt;code&gt;TRUNC&lt;/code&gt;, &lt;code&gt;NVL&lt;/code&gt;, or arithmetic around the key turns pruning off and the table scans itself. Filter with a bare-column range instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An implicit type conversion.&lt;/strong&gt; Comparing a &lt;code&gt;DATE&lt;/code&gt; partition key to a string literal (&lt;code&gt;WHERE order_date = '2025-06-15'&lt;/code&gt;) makes Oracle apply an internal conversion to the column — same effect as wrapping it in a function. Compare dates to &lt;code&gt;DATE&lt;/code&gt; literals and numbers to numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading &lt;code&gt;KEY&lt;/code&gt; as broken.&lt;/strong&gt; Seeing &lt;code&gt;Pstart = KEY&lt;/code&gt; and assuming pruning failed, then ripping out bind variables to "fix" it. &lt;code&gt;KEY&lt;/code&gt; is dynamic pruning — it's working. &lt;code&gt;PARTITION RANGE ALL&lt;/code&gt; is the broken case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partitioning on the wrong key.&lt;/strong&gt; Partitioning by a column the queries don't filter on. Pruning can only use the partition key; if your access pattern filters by &lt;code&gt;customer_id&lt;/code&gt; but you partitioned by &lt;code&gt;region&lt;/code&gt;, nothing prunes. Partition for how the data is &lt;em&gt;queried&lt;/em&gt;, not how it's shaped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting pruning to replace an index.&lt;/strong&gt; Pruning narrows &lt;em&gt;which segments&lt;/em&gt; are read; within a partition you still scan or index as usual. A month's partition that's still millions of rows may need a local index on top of the pruning. The two work together — &lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;reading the plan&lt;/a&gt; tells you which one you're missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global indexes that unravel on partition maintenance.&lt;/strong&gt; Dropping or truncating a partition invalidates global indexes unless you maintain them (&lt;code&gt;UPDATE INDEXES&lt;/code&gt;), quietly turning a fast partition drop into an index rebuild. Prefer local indexes with partitioning unless a global one is genuinely required.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What is partition pruning in Oracle?
&lt;/h3&gt;

&lt;p&gt;Partition pruning is the optimizer eliminating partitions that a query cannot possibly need and reading only the ones that remain. When a query filters on the partition key in a way Oracle can map to partition boundaries, it accesses just the matching partitions and skips the rest, which is the primary performance benefit of partitioning. It happens before access-method selection and does not depend on indexes: it operates at the level of deciding which physical segments are in play. In the execution plan it appears as operations such as PARTITION RANGE SINGLE, PARTITION RANGE ITERATOR, or PARTITION RANGE ALL, with Pstart and Pstop columns showing which partitions are touched. Reading one partition instead of the whole table is what turns partitioning into speed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my partitioned table still doing a full scan of every partition?
&lt;/h3&gt;

&lt;p&gt;The most common cause is a predicate the optimizer cannot map to partition boundaries. Wrapping the partition key in a function such as TO_CHAR, TRUNC, or NVL, doing arithmetic on it, or triggering an implicit type conversion (for example comparing a DATE partition key to a string literal) all hide the key, so the optimizer falls back to PARTITION RANGE ALL and scans every partition even though the query returns the same rows. The fix is to filter with a range on the bare partition key, such as order_date &amp;gt;= DATE start AND order_date &amp;lt; DATE end, and to compare dates to DATE literals and numbers to numbers so no conversion is applied to the column. A plan showing Pstart = 1 and Pstop equal to the last partition is the signature of pruning being defeated.&lt;/p&gt;

&lt;h3&gt;
  
  
  What do Pstart and Pstop mean in an execution plan?
&lt;/h3&gt;

&lt;p&gt;Pstart and Pstop are the first and last partitions the operation will access. When they show specific numbers that are equal, such as 19 and 19, the query is reading a single partition (PARTITION RANGE SINGLE). When they show a small range like 19 and 21, it is reading a contiguous set (PARTITION RANGE ITERATOR). When they show the value KEY, pruning is happening dynamically at run time, typically because a bind variable is involved, and the specific partitions are decided during execution rather than at parse time. When Pstart is 1 and Pstop is the last partition (often shown as a large sentinel number on interval-partitioned tables), the operation is PARTITION RANGE ALL, meaning no pruning occurred and every partition is read.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between static and dynamic partition pruning?
&lt;/h3&gt;

&lt;p&gt;Static pruning happens at parse time, when the optimizer can determine the exact partitions from the predicate because it uses literals or otherwise-resolvable values; the plan shows actual partition numbers in Pstart and Pstop. Dynamic pruning happens at execution time, most often because the predicate uses bind variables whose values are not known until the query runs; the plan shows KEY in Pstart and Pstop to indicate that pruning will occur but the specific partitions are chosen at run time. Dynamic pruning is not a problem and is fully expected with bind variables, which remain the right default for cursor reuse. Both are healthy; the case to worry about is PARTITION RANGE ALL, where no pruning of any kind takes place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does partition pruning replace indexes?
&lt;/h3&gt;

&lt;p&gt;No. Partition pruning decides which partitions to read; it does not decide how to read within a partition. If a pruned partition is still large, a query that returns a small fraction of it may still need an index to avoid scanning the whole partition. Pruning and indexing are complementary: pruning narrows the segments, and an index (usually a local index, aligned with the partitioning) narrows the rows within them. A common tuning outcome is a query that prunes correctly to one partition but still scans that entire partition because the selective predicate on a non-key column has no supporting index. Reading the execution plan shows whether the cost is coming from touching too many partitions or from scanning too much within one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use a local or global index on a partitioned table?
&lt;/h3&gt;

&lt;p&gt;Prefer local indexes with partitioning unless there is a specific reason not to. A local index is partitioned the same way as the table, so each index partition corresponds to one table partition; this keeps partition maintenance operations such as dropping or truncating a partition fast and localized, and it aligns naturally with pruning. A global index spans all partitions and can be necessary when you need to enforce uniqueness on a column that is not the partition key or to support a query pattern that crosses partitions efficiently, but it becomes invalid when a partition is dropped or truncated unless you maintain it with the UPDATE INDEXES clause, which turns a fast metadata operation into an index rebuild. Choose local by default and reach for global only for the cases that require it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does partitioning require a separate Oracle license?
&lt;/h3&gt;

&lt;p&gt;The Oracle Partitioning option is a separately licensed option of Oracle Database Enterprise Edition on-premises, so using partitioning on-prem Enterprise Edition requires that license. In Oracle Cloud Infrastructure, including Autonomous Database and the Base Database and Exadata cloud services, partitioning is included. It is also available in Oracle Database Free for development and testing, which is what makes it straightforward to reproduce pruning behavior in a local container. Because licensing depends on edition and platform and can change, confirm your specific entitlement before relying on partitioning in production on-premises rather than assuming it is included.&lt;/p&gt;

&lt;p&gt;Pruning is the same discipline as the rest of the performance series: know what the optimizer is actually doing and give it what it needs to do the fast thing. &lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;Reading the execution plan&lt;/a&gt; is how you catch &lt;code&gt;PARTITION RANGE ALL&lt;/code&gt; in the first place; &lt;a href="https://uptimearchitect.com/blog/oracle-optimizer-statistics-demystified/" rel="noopener noreferrer"&gt;good statistics&lt;/a&gt; keep the row estimates that drive the plan honest; and &lt;a href="https://uptimearchitect.com/blog/oracle-sql-plan-management-baselines/" rel="noopener noreferrer"&gt;SQL plan baselines&lt;/a&gt; keep a plan that prunes today from quietly regressing tomorrow. Partition for how the data is queried, keep functions off the partition key, and check &lt;code&gt;Pstart&lt;/code&gt;/&lt;code&gt;Pstop&lt;/code&gt; when a query reads more than it should. Then prove it the way that ends the argument — with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/partition-pruning" rel="noopener noreferrer"&gt;partition pruning lab&lt;/a&gt;, where the same query reads one partition or twenty-four depending on a single function call.&lt;/p&gt;




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

</description>
      <category>oracle</category>
      <category>partitionpruning</category>
      <category>partitioning</category>
      <category>intervalpartitioning</category>
    </item>
    <item>
      <title>Oracle Data Redaction: Mask at Read Time, Prove It Isn't Access Control</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sat, 05 Sep 2026 19:08:34 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/oracle-data-redaction-mask-at-read-time-prove-it-isnt-access-control-6ng</link>
      <guid>https://dev.to/uptimearchitect/oracle-data-redaction-mask-at-read-time-prove-it-isnt-access-control-6ng</guid>
      <description>&lt;p&gt;The support rep sees &lt;code&gt;****-****-****-0001&lt;/code&gt; on their screen and everyone relaxes. The card number is "masked," the demo looks compliant, the box gets ticked. Then someone runs an export, or a report built on the same table, or reads the datafile off disk — and the full number is right there, exactly where it always was, unchanged.&lt;/p&gt;

&lt;p&gt;That gap between what redaction &lt;em&gt;looks&lt;/em&gt; like and what it &lt;em&gt;does&lt;/em&gt; is where teams get burned. &lt;strong&gt;Data Redaction&lt;/strong&gt; (the &lt;code&gt;DBMS_REDACT&lt;/code&gt; package, part of Advanced Security) is a genuinely useful, near-zero-friction control: it masks sensitive columns as a query returns them, with no application change. But it is constantly mistaken for two things it is emphatically not — encryption at rest, and access control. Get that wrong and you've put a sticker over the problem. This post is what redaction actually is, the four ways to mask, who gets masked and who doesn't, and — the part that ends the argument — a lab that proves the boundary by reading the real data straight off the disk while the screen still says &lt;code&gt;****&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What redaction is — and the two things it isn't
&lt;/h2&gt;

&lt;p&gt;Redaction is a &lt;strong&gt;read-time transform&lt;/strong&gt;. When a query returns a protected column, Oracle rewrites the value on the way out — to a mask, a partial value, a pattern, or a random stand-in — based on a policy you attach to the table. The application doesn't change, the SQL doesn't change, and, crucially, &lt;strong&gt;the stored data doesn't change at all&lt;/strong&gt;. Nothing is written. The bytes in the block are the same before and after you add the policy; only the &lt;em&gt;result the client receives&lt;/em&gt; is different.&lt;/p&gt;

&lt;p&gt;That single fact defines both edges of what redaction is good for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is not encryption at rest.&lt;/strong&gt; The real value is still sitting in the datafile, in every RMAN backup, in redo, and in a Data Pump export taken by a privileged user. Redaction never touches the bytes on disk. If the threat is a stolen datafile or a lost backup, redaction does nothing — that's what &lt;a href="https://uptimearchitect.com/blog/oracle-transparent-data-encryption-tde/" rel="noopener noreferrer"&gt;Transparent Data Encryption&lt;/a&gt; is for, and the two are complementary, not alternatives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not access control.&lt;/strong&gt; The &lt;code&gt;WHERE&lt;/code&gt; clause is evaluated against the &lt;em&gt;real&lt;/em&gt; value, not the masked one, so a user who only ever sees &lt;code&gt;****-****-****-0001&lt;/code&gt; can still confirm a full card number by guessing it in a predicate. Exports, expressions, and inference all route around the mask. Redaction reduces &lt;em&gt;casual&lt;/em&gt; exposure — the over-the-shoulder look, the screenshot, the ad-hoc report — and shrinks who sees full values &lt;em&gt;by default&lt;/em&gt;. It does not stop a determined, authorized user.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right mental model: redaction is the &lt;strong&gt;last-mile display filter&lt;/strong&gt;. It sits on top of privileges, TDE, and &lt;a href="https://uptimearchitect.com/blog/oracle-unified-auditing/" rel="noopener noreferrer"&gt;auditing&lt;/a&gt; — not instead of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways to mask
&lt;/h2&gt;

&lt;p&gt;A redaction policy attaches to a table and names one or more columns, each with a &lt;strong&gt;function type&lt;/strong&gt; that decides how the value is masked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FULL&lt;/code&gt;&lt;/strong&gt; — replace the whole value with a fixed default: &lt;code&gt;0&lt;/code&gt; for numbers, a single space for character data. The blunt instrument; use it when no part of the value should show.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PARTIAL&lt;/code&gt;&lt;/strong&gt; — keep some of the value and mask the rest. This is the one you'll use most: show the last four digits of a card, the last four of an SSN, the area code of a phone number. Oracle ships built-in format constants for the common cases (&lt;code&gt;REDACT_CCN16_F12&lt;/code&gt; for a 16-digit card, &lt;code&gt;REDACT_US_SSN_F5&lt;/code&gt; for a US SSN) so you don't hand-roll the position math.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;REGEXP&lt;/code&gt;&lt;/strong&gt; — pattern-based masking, for values without a fixed layout. The classic is an email address: mask the name part, keep the domain, so &lt;code&gt;user0001@example.com&lt;/code&gt; becomes &lt;code&gt;xxxx@example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;RANDOM&lt;/code&gt;&lt;/strong&gt; — replace the value with a random one of the same type. Useful when a real-&lt;em&gt;looking&lt;/em&gt; value keeps a screen or a downstream process happy but the actual data must not leak.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(There's also &lt;code&gt;NULLIFY&lt;/code&gt;, which returns &lt;code&gt;NULL&lt;/code&gt;, and &lt;code&gt;NONE&lt;/code&gt;, a pass-through you can use to stage a policy before turning it on.) Building the policy is two calls — &lt;code&gt;ADD_POLICY&lt;/code&gt; creates it on the first column, &lt;code&gt;ALTER_POLICY&lt;/code&gt; adds the rest:&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="c1"&gt;-- card: PARTIAL, using the built-in 16-digit credit-card format -&amp;gt; shows only the last 4 digits&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ADD_POLICY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;object_schema&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'REDOWN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;object_name&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'CUSTOMERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;policy_name&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'RED_CUSTOMERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;column_name&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'CARD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;function_type&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;PARTIAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;function_parameters&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REDACT_CCN16_F12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expression&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1=1'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                 &lt;span class="c1"&gt;-- who to redact; see below&lt;/span&gt;

  &lt;span class="c1"&gt;-- ssn: PARTIAL, built-in US-SSN format -&amp;gt; XXX-XX-6789&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ALTER_POLICY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;object_schema&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'REDOWN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'CUSTOMERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'RED_CUSTOMERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ADD_COLUMN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SSN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;function_type&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;PARTIAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;function_parameters&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REDACT_US_SSN_F5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;-- email: REGEXP -&amp;gt; mask the name, keep the domain&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ALTER_POLICY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;object_schema&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'REDOWN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'CUSTOMERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'RED_CUSTOMERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;                &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ADD_COLUMN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'EMAIL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;function_type&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REGEXP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;regexp_pattern&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RE_PATTERN_EMAIL_ADDRESS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;regexp_replace_string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RE_REDACT_EMAIL_NAME&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;-- salary: FULL -&amp;gt; a NUMBER redacts to 0&lt;/span&gt;
  &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ALTER_POLICY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;object_schema&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'REDOWN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'CUSTOMERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'RED_CUSTOMERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ADD_COLUMN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SALARY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;function_type&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DBMS_REDACT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;FULL&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;One policy per table, as many columns as you like. Note there's no &lt;code&gt;GRANT&lt;/code&gt;, no view, no application change — the table is the same table it was a moment ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who gets redacted, and who doesn't
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;expression&lt;/code&gt; parameter is the whole game for &lt;em&gt;targeting&lt;/em&gt;. It's a SQL predicate evaluated per session: when it's true, the session is redacted; when it's false, that session sees the real value. &lt;code&gt;1=1&lt;/code&gt; above redacts everyone who is subject to the policy — fine for a demo, wrong for production. A real policy targets by identity or context:&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;-- redact for everyone EXCEPT sessions that hold a role trusted to see full PII&lt;/span&gt;
&lt;span class="n"&gt;expression&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SYS_CONTEXT(&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;SYS_SESSION_ROLES&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;PII_FULL&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;) IS NULL'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can key the expression off the session user, an enabled role, an application context, the client identifier, or the connection's IP — anything you can express in SQL. The one rule: &lt;strong&gt;base it on something the user can't spoof.&lt;/strong&gt; Redacting on a &lt;code&gt;CLIENT_IDENTIFIER&lt;/code&gt; that the application sets for its own convenience is a mask anyone can lift by setting the identifier themselves.&lt;/p&gt;

&lt;p&gt;Above the expression sits a hard override: the &lt;strong&gt;&lt;code&gt;EXEMPT REDACTION POLICY&lt;/code&gt;&lt;/strong&gt; system privilege. A user who holds it bypasses &lt;em&gt;every&lt;/em&gt; redaction policy in the database, regardless of any expression. &lt;code&gt;SYS&lt;/code&gt; is always exempt. Grant the privilege deliberately, to as few accounts as possible, and audit who has it — it is the master key to every mask you've set.&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%3AeNp1klFv0zAQx7_KyS8diGRN2QsVDJXWE2gNypJKgBYeXPu6hCZ2ZDvbqq7fHdvNRh_GPTiK7-53_7vznnAlkEyBbBr1wCumLawWpQS4uS3JTY96BxutWpgvaX79ca3PLwu6pPMVcKYFxHEMP77SnB5_P8HoIkmSaOIseu8sGo_HyagkvyGKLuHKIY_RupcGlARbIeR0toR71vQY8GctM9ta3kHFjBxZ9-k6lChgh_aNI3ltVwGXO1zKLK98tFYPoJGJo1rPFcyyTd0cqV3Damnx0U7hFYUDNg_YbF-SHAXjtnYKO9XUfDcNkNoEsEFjvIv-pGm2-lySg0_OfPJTSaSCsp-Mkwsw_foPcgtnYXZO-xOkTnKmlb92HXHV9K2EdFZc00Wo4DKTDxN46yz6d5xIfK6yQ_NcBh-x7SycQ_GrcOfCtQ1Z33a-3vy1erMi-laclvv_RFwLd5p1lVtSsbo9nYvEe9RgVc8rDGMxOOQALFzkYpi-37KozfbF-cVj0tl3WDO-7buXezokBe2uqU5pOzhRimE9sV-xCTuOoyCKvAPSom5ZLdwr3hOnpA3vWeCG9Y0lh8Nfpongrg" 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%3AeNp1klFv0zAQx7_KyS8diGRN2QsVDJXWE2gNypJKgBYeXPu6hCZ2ZDvbqq7fHdvNRh_GPTiK7-53_7vznnAlkEyBbBr1wCumLawWpQS4uS3JTY96BxutWpgvaX79ca3PLwu6pPMVcKYFxHEMP77SnB5_P8HoIkmSaOIseu8sGo_HyagkvyGKLuHKIY_RupcGlARbIeR0toR71vQY8GctM9ta3kHFjBxZ9-k6lChgh_aNI3ltVwGXO1zKLK98tFYPoJGJo1rPFcyyTd0cqV3Damnx0U7hFYUDNg_YbF-SHAXjtnYKO9XUfDcNkNoEsEFjvIv-pGm2-lySg0_OfPJTSaSCsp-Mkwsw_foPcgtnYXZO-xOkTnKmlb92HXHV9K2EdFZc00Wo4DKTDxN46yz6d5xIfK6yQ_NcBh-x7SycQ_GrcOfCtQ1Z33a-3vy1erMi-laclvv_RFwLd5p1lVtSsbo9nYvEe9RgVc8rDGMxOOQALFzkYpi-37KozfbF-cVj0tl3WDO-7buXezokBe2uqU5pOzhRimE9sV-xCTuOoyCKvAPSom5ZLdwr3hOnpA3vWeCG9Y0lh8Nfpongrg" alt="Where redaction happens in the read path — and where it doesn't. The WHERE clause is evalu" width="725" height="1076"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where redaction happens in the read path — and where it doesn't. The WHERE clause is evaluated against the real stored value (so predicates and inference see plaintext); only the projected column that comes back is masked, and only for a session that isn't exempt. The datafile, backups, and exports are never touched.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Put the same rows behind the policy above, then read them back as two different users — an ordinary &lt;code&gt;CLERK&lt;/code&gt;, who is subject to the policy, and an &lt;code&gt;AUDITOR&lt;/code&gt;, who holds &lt;code&gt;EXEMPT REDACTION POLICY&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;-- as CLERK (subject to the policy)          -- as AUDITOR (EXEMPT REDACTION POLICY)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ssn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salary&lt;/span&gt;              &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ssn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salary&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;redown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customers&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;1&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;redown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customers&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;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;column&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;CLERK&lt;/code&gt; (subject)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;AUDITOR&lt;/code&gt; (exempt)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;****-****-****-0001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4111-2222-3333-0001&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;XXX-XX-0001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;123-45-0001&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xxxx@example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;user0001@example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;salary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;50001&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same row, same instant, one policy. The clerk sees masks; the auditor sees plaintext. That's the feature working exactly as intended — masking decided per session, at read time, with the stored data untouched underneath. The proof that it's untouched is right there in the auditor's column: nothing was destroyed to produce the mask.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it &lt;em&gt;doesn't&lt;/em&gt; do — the same lab, two more reads
&lt;/h2&gt;

&lt;p&gt;Here's where the myths die. First, redaction is &lt;strong&gt;not encryption at rest&lt;/strong&gt;. Read the datafile off disk the way a thief with a copy of your storage would — not through the database, which redacts, 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;# the real card prefix, written to the ordinary datafile in the clear:&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;'4111-2222-3333'&lt;/span&gt; /opt/oracle/oradata/FREE/FREEPDB1/red_data.dbf   &lt;span class="c"&gt;# -&amp;gt; 162   (still there)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;162 hits. The real card numbers are sitting in the datafile in plaintext, exactly where redaction left them — because redaction never touched the disk. Contrast that with the &lt;a href="https://uptimearchitect.com/blog/oracle-transparent-data-encryption-tde/" rel="noopener noreferrer"&gt;TDE lab&lt;/a&gt;, where the same kind of &lt;code&gt;grep&lt;/code&gt; finds &lt;strong&gt;zero&lt;/strong&gt; hits in the encrypted datafile. That's the whole distinction in one command: encryption removes the data from the file; redaction leaves it and masks the query.&lt;/p&gt;

&lt;p&gt;Second, redaction is &lt;strong&gt;not access control&lt;/strong&gt;. Two ways to see it. Try to peel the mask off with an expression and Oracle stops you — a redacted column used inside most functions returns &lt;code&gt;NULL&lt;/code&gt;, a deliberate anti-tamper:&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;-- as CLERK:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'the card is '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;redown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customers&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;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- returns NULL, not the real value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But that same protection doesn't extend to the &lt;em&gt;predicate&lt;/em&gt;, and that's the hole. The &lt;code&gt;WHERE&lt;/code&gt; clause runs against the real stored value, so a user who has only ever seen &lt;code&gt;****&lt;/code&gt; can still confirm — or enumerate — the true value by guessing it in a filter:&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;-- as CLERK, who "can't see" full card numbers:&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;redown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'4111-2222-3333-0001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- 1  -&amp;gt; confirmed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The count comes back &lt;code&gt;1&lt;/code&gt;. The mask on the screen never mattered; the filter saw the real data. Redaction masks the &lt;em&gt;output&lt;/em&gt;, not the &lt;em&gt;query&lt;/em&gt;. A user you don't trust with the data shouldn't be able to query the table at all — that's a &lt;code&gt;GRANT&lt;/code&gt;, not a mask.&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/data-redaction" rel="noopener noreferrer"&gt;Data Redaction lab&lt;/a&gt; stands up an Oracle Database Free container, builds one table behind a four-column &lt;code&gt;DBMS_REDACT&lt;/code&gt; policy, and reads it back as a subject &lt;code&gt;CLERK&lt;/code&gt; and an &lt;code&gt;EXEMPT&lt;/code&gt; &lt;code&gt;AUDITOR&lt;/code&gt;. It &lt;strong&gt;asserts&lt;/strong&gt; the clerk sees masks and the auditor sees plaintext, then &lt;code&gt;grep&lt;/code&gt;s the datafile and asserts the real card numbers are &lt;em&gt;still on disk&lt;/em&gt; (redaction ≠ encryption), and asserts a &lt;code&gt;WHERE&lt;/code&gt; clause on the true value still matches (redaction ≠ access control). If the clerk ever sees a real value, or the data isn't on disk, the run fails. It's 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;Treating redaction as encryption.&lt;/strong&gt; "The PII is redacted, so we don't need TDE." The datafile, the backup, and the export all still hold the real values. Redaction is a screen filter; the disk is plaintext. Pair it with TDE — one protects the file, the other narrows the view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating redaction as access control.&lt;/strong&gt; Letting users who shouldn't see PII query the table because "it's redacted anyway." Inference through predicates, full Data Pump exports, and any exempt account route straight around the mask. If someone shouldn't have the data, revoke the access — don't mask it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redacting on a spoofable expression.&lt;/strong&gt; Basing the policy on a &lt;code&gt;CLIENT_IDENTIFIER&lt;/code&gt; or context value the application sets for convenience. Anyone who can set the same value lifts the mask. Key the expression off roles or session facts the user can't forge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting the bypass paths.&lt;/strong&gt; &lt;code&gt;EXEMPT REDACTION POLICY&lt;/code&gt; holders and &lt;code&gt;SYS&lt;/code&gt; see everything; a full Data Pump export carries the real data; a &lt;code&gt;CREATE TABLE AS SELECT&lt;/code&gt; run by an exempt user materializes plaintext into a new, unprotected table. Audit who is exempt, and treat exports as sensitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Masking a column the application computes on.&lt;/strong&gt; Because a redacted column returns &lt;code&gt;NULL&lt;/code&gt; inside expressions, redacting a value the app concatenates, hashes, or does arithmetic on can silently turn results into &lt;code&gt;NULL&lt;/code&gt;. Redact what's &lt;em&gt;displayed&lt;/em&gt;, not what's &lt;em&gt;processed&lt;/em&gt; — and test the app against the policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confusing it with the Data Masking pack.&lt;/strong&gt; Redaction is &lt;em&gt;dynamic&lt;/em&gt; — it happens live, on production, at read time, and the data stays real. Static data masking (subsetting) &lt;em&gt;permanently&lt;/em&gt; rewrites values, and is for building non-production copies. Different tools for different jobs; don't reach for redaction to sanitize a test clone.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What is Oracle Data Redaction (DBMS_REDACT)?
&lt;/h3&gt;

&lt;p&gt;Data Redaction is an Oracle Advanced Security feature, managed through the DBMS_REDACT package, that masks the values of sensitive columns as a query returns them. It is a read-time, or dynamic, control: when a session subject to a redaction policy selects a protected column, Oracle rewrites the returned value to a mask, a partial value, a regular-expression replacement, or a random stand-in, based on a policy attached to the table. The application, the SQL, and — most importantly — the data stored on disk are unchanged; only the result delivered to the client is transformed. It requires no application changes and is commonly used to keep full PII such as card numbers, national IDs, and salaries off screens and reports for staff who only need to see part of the value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Data Redaction change or encrypt the stored data?
&lt;/h3&gt;

&lt;p&gt;No. Redaction never modifies the data at rest. The real value stays in the datafile, in redo, in RMAN backups, and in Data Pump exports taken by a privileged user; redaction only alters what a query returns to a subject session. This is the single most important thing to understand about it: redaction is not encryption at rest and provides no protection if the datafile or a backup is stolen. You can prove it by reading the datafile off disk with grep, where the real values are plainly visible. To protect the data in the files and backups you need Transparent Data Encryption (TDE), which is a separate and complementary control — TDE encrypts the bytes on disk, redaction narrows who sees full values on screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between Data Redaction, TDE, and Data Masking?
&lt;/h3&gt;

&lt;p&gt;The three solve different problems. Transparent Data Encryption encrypts data at rest — the bytes in datafiles and backups — and defends against theft of the files. Data Redaction masks values dynamically at read time on the live database, leaving the stored data intact, and reduces who sees full sensitive values by default. Static Data Masking (subsetting) permanently rewrites sensitive values to create sanitized non-production copies, so a test or development database contains realistic but fake data. A mature setup uses all three: TDE so a stolen file is unreadable, redaction so production screens show only what each role needs, and static masking so non-production environments never contain real PII in the first place.&lt;/p&gt;

&lt;h3&gt;
  
  
  What redaction function types does DBMS_REDACT support?
&lt;/h3&gt;

&lt;p&gt;There are several. FULL replaces the entire value with a fixed default — zero for numbers, a single space for character data. PARTIAL keeps part of the value and masks the rest, such as showing only the last four digits of a credit-card number or a Social Security number, and Oracle provides built-in format constants for common cases. REGEXP uses a regular expression to mask by pattern, for example masking the name portion of an email address while keeping the domain. RANDOM substitutes a random value of the same datatype, useful when a realistic-looking but fake value is needed. NULLIFY returns NULL, and NONE is a pass-through used to stage or temporarily disable a policy. A single policy can apply different function types to different columns of the same table.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I control which users see redacted data versus real data?
&lt;/h3&gt;

&lt;p&gt;Two mechanisms. First, every policy has an expression, a SQL predicate evaluated per session: when it is true the session is redacted, when false it sees the real value. You typically key the expression off the session user, an enabled role, an application context, or another session fact — but it must be something the user cannot spoof, so roles are safer than a client identifier the application sets. Second, the EXEMPT REDACTION POLICY system privilege overrides everything: any user holding it bypasses all redaction policies regardless of their expressions, and SYS is always exempt. Grant that privilege to as few accounts as possible and audit who holds it, because it is effectively the master key to every mask in the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a user bypass Oracle Data Redaction?
&lt;/h3&gt;

&lt;p&gt;Redaction is not access control, and an authorized user who can query the table has several routes around the mask. The WHERE clause is evaluated against the real value, so a user can confirm or enumerate true values by testing them in a predicate even though the projected column is masked. A user with the EXEMPT REDACTION POLICY privilege, or SYS, sees everything. A full Data Pump export by a privileged account carries the real data, and a CREATE TABLE AS SELECT run by an exempt user copies plaintext into a new, unprotected table. Redaction reduces casual and default exposure of full values; it does not stop a determined user who is authorized to query the data. The control that actually restricts who can read the data is privileges — least-privilege grants — not the mask.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Data Redaction slow down queries or affect indexes?
&lt;/h3&gt;

&lt;p&gt;The masking transform itself is lightweight, applied to the rows as they are projected back to the client, so the direct overhead is small. Importantly, because the WHERE clause and joins operate on the real underlying values rather than the masked output, redaction does not prevent the optimizer from using indexes or change which rows match — the query plan is unaffected by the policy. The practical performance and correctness caveat is different: because a redacted column returns NULL when used inside most SQL expressions and functions, application code that computes on a redacted column can silently produce NULL results. The guidance is to redact columns that are displayed rather than columns the application processes, and to test the application against the policy before enabling it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Oracle Data Redaction require a separate license?
&lt;/h3&gt;

&lt;p&gt;Data Redaction is part of the Oracle Advanced Security option. On-premises Enterprise Edition, Advanced Security is a separately licensed pack, so using redaction there requires that license — the same pack that covers TDE. In Oracle Cloud Infrastructure, including Autonomous Database and the Base Database and Exadata cloud services, Advanced Security is included. It is also available in Oracle Database Free for development and testing, which is what makes it straightforward to try in a local container. Because licensing depends on your edition and platform and can change, confirm your specific entitlement before enabling redaction in production on-premises rather than assuming it is included.&lt;/p&gt;

&lt;p&gt;Redaction is the third leg of the same data-security discipline as the rest: &lt;a href="https://uptimearchitect.com/blog/oracle-transparent-data-encryption-tde/" rel="noopener noreferrer"&gt;TDE&lt;/a&gt; makes the files unreadable when they're stolen, &lt;a href="https://uptimearchitect.com/blog/oracle-unified-auditing/" rel="noopener noreferrer"&gt;unified auditing&lt;/a&gt; records who looked, and the &lt;a href="https://uptimearchitect.com/blog/oracle-database-hardening-checklist/" rel="noopener noreferrer"&gt;hardening checklist&lt;/a&gt; and least-privilege grants decide who can query at all. Redaction sits on top of those, narrowing what full values reach a screen or a report by default. Use it for exactly that — and never as a stand-in for the layer beneath it. Then prove the boundary the way that ends the argument: with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/data-redaction" rel="noopener noreferrer"&gt;Data Redaction lab&lt;/a&gt;, where the screen says &lt;code&gt;****&lt;/code&gt; and the datafile, read off disk, says otherwise.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-data-redaction-mask-at-read-time/" rel="noopener noreferrer"&gt;uptimearchitect.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>dataredaction</category>
      <category>dbmsredact</category>
      <category>datamasking</category>
    </item>
    <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>
  </channel>
</rss>
