<?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%2Feafab265-85f4-404d-a6a8-1322e9be60e1.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>ORA-12514: The Database Is Up. The Listener Just Doesn't Know It.</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sun, 01 Nov 2026 13:41:46 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/ora-12514-the-database-is-up-the-listener-just-doesnt-know-it-1o5d</link>
      <guid>https://dev.to/uptimearchitect/ora-12514-the-database-is-up-the-listener-just-doesnt-know-it-1o5d</guid>
      <description>&lt;p&gt;The application can't connect. The error is one every Oracle DBA has seen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ORA-12514: TNS:listener does not currently know of service requested in connect descriptor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Newer clients word it more plainly. The 26ai client in this post's lab says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ORA-12514: Cannot connect to database. Service FREEPDB is not registered with the listener
at host 127.0.0.1 port 1521.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database is up. You can log in on the server. So someone restarts the listener, and now &lt;em&gt;nothing&lt;/em&gt; can connect for a minute.&lt;/p&gt;

&lt;p&gt;ORA-12514 is a precise message, and it's good news of a kind: &lt;strong&gt;the listener answered.&lt;/strong&gt; It's running, the host and port are right, and the network path works. What it doesn't have is a service by the name the client asked for. The database behind it may be perfectly healthy. This post covers how services get to the listener, the four common reasons one is missing, and how to tell ORA-12514 from its neighbours. It ends with a lab where restarting the listener &lt;strong&gt;causes&lt;/strong&gt; a minute of ORA-12514 for every service, and one &lt;code&gt;ALTER SYSTEM REGISTER&lt;/code&gt; closes the gap at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a service gets to the listener
&lt;/h2&gt;

&lt;p&gt;The listener doesn't know what databases exist. The database tells it. A background process (LREG) in each instance &lt;strong&gt;registers&lt;/strong&gt; the instance's services with the listener: &lt;code&gt;FREEPDB1&lt;/code&gt;, each application service, the CDB's own service. It does that at startup, whenever a service starts or stops, and periodically after that. The listener keeps that list in memory only.&lt;/p&gt;

&lt;p&gt;A client connects by asking for a service: &lt;code&gt;host:1521/FREEPDB1&lt;/code&gt; in EZConnect, or &lt;code&gt;SERVICE_NAME=FREEPDB1&lt;/code&gt; in a &lt;code&gt;tnsnames.ora&lt;/code&gt; descriptor. If the listener has a registered service by that name, it hands the connection to the instance. If not, it refuses with ORA-12514.&lt;/p&gt;

&lt;p&gt;So ORA-12514 has exactly two kinds of cause: &lt;strong&gt;the client asked for the wrong name&lt;/strong&gt;, or &lt;strong&gt;the right name isn't registered&lt;/strong&gt; with &lt;em&gt;this&lt;/em&gt; listener right now. &lt;code&gt;lsnrctl services&lt;/code&gt; (or &lt;code&gt;lsnrctl status&lt;/code&gt;) shows what the listener has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Services Summary...
Service "FREE" has 1 instance(s).
Service "FREEXDB" has 1 instance(s).
Service "freepdb1" has 1 instance(s).
Service "orders_app" has 1 instance(s).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the service is in that list, look at the client. If it isn't, look at the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which error do you actually have?
&lt;/h2&gt;

&lt;p&gt;Five errors get mixed up in tickets. They fail at different points in the connection, so they point to different fixes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ORA-12154   the alias wasn't found (tnsnames.ora, LDAP)    the client never reached a listener
ORA-12541   no listener at that host and port              wrong host/port, listener down, firewall
ORA-12505   the listener doesn't know that SID             often a PDB name given as a SID
ORA-12514   the listener doesn't know that SERVICE         this post
ORA-12504   no service name given, and no default service  incomplete connect string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ORA-12505 is the usual developer version. &lt;code&gt;jdbc:oracle:thin:@db01:1521:FREEPDB1&lt;/code&gt; (a colon before the name) asks for a &lt;strong&gt;SID&lt;/strong&gt;, which is an instance, not a PDB. &lt;code&gt;jdbc:oracle:thin:@//db01:1521/FREEPDB1&lt;/code&gt; (a slash) asks for the service, which is what a PDB is reached by.&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%3AeNptktFKwzAUhl_lkBsVOsZEb4ZM6lZkUN1oiyBWJEuyLS5LSpJuyty7e9La4cXuQsL5_u_85ECY4YIMgSyV2bM1tR6KSakBkreSzLK4N7i-HdyU5B16vRGkh5JMHfi1ACfsTjIBUt8tbH-knLbMq-7a3ZfkGDBpmPspybdwJfmBMVILnGZKCu2Buo3gsDQWqDZItaDpVjTAS7YWbNNEKem80PiozOoKVf5ztYkgjEq9AopApUJMhjFpN_VZOw9WOI_LCT5s6HFaJBnkr3mRPEGWPE7xkJ1Bh_j55AFks7Rt1Ra1D5nNI62qbucQnGNw_tcMl1xfeGhyYS_9uqO1Cu299BHk8UsCeREXCfTB2R32eEaFao4AFOnqaCl1VRnrHQqdug8icxSZUE8X1KGJES6oWLEKsxbCKq3FUn5BOhvH6UcaOng-tcAUdQ7GURbl0RxWxnASAdkKu6WS44c5ENxm23wdLpa0Vp4cj7-i8MJd" 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%3AeNptktFKwzAUhl_lkBsVOsZEb4ZM6lZkUN1oiyBWJEuyLS5LSpJuyty7e9La4cXuQsL5_u_85ECY4YIMgSyV2bM1tR6KSakBkreSzLK4N7i-HdyU5B16vRGkh5JMHfi1ACfsTjIBUt8tbH-knLbMq-7a3ZfkGDBpmPspybdwJfmBMVILnGZKCu2Buo3gsDQWqDZItaDpVjTAS7YWbNNEKem80PiozOoKVf5ztYkgjEq9AopApUJMhjFpN_VZOw9WOI_LCT5s6HFaJBnkr3mRPEGWPE7xkJ1Bh_j55AFks7Rt1Ra1D5nNI62qbucQnGNw_tcMl1xfeGhyYS_9uqO1Cu299BHk8UsCeREXCfTB2R32eEaFao4AFOnqaCl1VRnrHQqdug8icxSZUE8X1KGJES6oWLEKsxbCKq3FUn5BOhvH6UcaOng-tcAUdQ7GURbl0RxWxnASAdkKu6WS44c5ENxm23wdLpa0Vp4cj7-i8MJd" alt="ORA-12514 means the listener answered and has no service by that name. Compare what the cl" width="764" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ORA-12514 means the listener answered and has no service by that name. Compare what the client asked for (the listener log records it) with what the listener has (lsnrctl services). A wrong name is a client fix; a missing registration is a database fix.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it
&lt;/h2&gt;

&lt;p&gt;The lab runs Oracle Database Free in a container. It creates an application service, &lt;code&gt;orders_app&lt;/code&gt;, in the pluggable database &lt;code&gt;FREEPDB1&lt;/code&gt; the way many shops do: with &lt;code&gt;DBMS_SERVICE&lt;/code&gt;, started by hand. Then it breaks the connection four ways, with the database up and healthy every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;case                                     result                                   fix
FREEPDB instead of FREEPDB1              ORA-12514; FREEPDB1 connects fine        fix the connect string
listener restarted                       ORA-12514 for every service, ~60 s       ALTER SYSTEM REGISTER
PDB closed and opened                    PDB READ WRITE, orders_app ORA-12514     start it + SAVE STATE
second listener on port 1522             "supports no services", ORA-12514        LOCAL_LISTENER
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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/listener-service" rel="noopener noreferrer"&gt;listener-service lab&lt;/a&gt; &lt;strong&gt;asserts&lt;/strong&gt; every row: ORA-12514 for the wrong name and the right name still connecting; ORA-12514 right after a listener restart and recovery once the database re-registers; the app service missing after a PDB restart and surviving the next one after &lt;code&gt;SAVE STATE&lt;/code&gt;; and the second listener refusing until &lt;code&gt;LOCAL_LISTENER&lt;/code&gt; names it. It also asserts ORA-12154, ORA-12541 and ORA-12505 for their cases. If any of that doesn't reproduce, the run fails. Proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Cause 1: the wrong name
&lt;/h2&gt;

&lt;p&gt;The most common cause, and the easiest to miss because the name &lt;em&gt;looks&lt;/em&gt; right. &lt;code&gt;FREEPDB&lt;/code&gt; instead of &lt;code&gt;FREEPDB1&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt; instead of &lt;code&gt;orders_app&lt;/code&gt;, a domain mismatch (the database has a &lt;code&gt;DB_DOMAIN&lt;/code&gt;, so its service is &lt;code&gt;freepdb1.example.com&lt;/code&gt;, and the client asks for plain &lt;code&gt;freepdb1&lt;/code&gt;, or the other way round), or a connect string still pointing at the old database's service name after a &lt;a href="https://uptimearchitect.com/blog/oracle-cloud-migration-methods/" rel="noopener noreferrer"&gt;migration&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You don't have to guess which app is asking for what. &lt;strong&gt;The listener log records every refused connect&lt;/strong&gt; with the service the client requested, the client program, and the client host. From the lab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(CONNECT_DATA=(SERVICE_NAME=FREEPDB)(CID=(PROGRAM=sqlplus)(HOST=...)(USER=oracle)))
  * (ADDRESS=(PROTOCOL=tcp)(HOST=127.0.0.1)(PORT=59690)) * establish * FREEPDB * 12514
TNS-12514: listener does not currently know of service requested in connect descriptor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even on 26ai the log uses the classic text. &lt;code&gt;lsnrctl status&lt;/code&gt; prints the log's location. On a busy listener, grep it for &lt;code&gt;12514&lt;/code&gt; and count by &lt;code&gt;SERVICE_NAME&lt;/code&gt; and &lt;code&gt;HOST&lt;/code&gt;: the typo and the server it lives on fall out in one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 2: the listener was restarted
&lt;/h2&gt;

&lt;p&gt;This is the one that turns a small problem into an outage. A listener knows only what's been registered with it since it started. Restart it, and for a while it knows nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ lsnrctl status
...
The listener supports no services
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every service gets ORA-12514 until the database registers again on its own. In the lab that took &lt;strong&gt;about a minute&lt;/strong&gt;, every time: 61 seconds of refused connections for every service, caused entirely by the restart. Restarting the listener to "fix" ORA-12514 makes it worse.&lt;/p&gt;

&lt;p&gt;The database can be told to register right now:&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;SYSTEM&lt;/span&gt; &lt;span class="n"&gt;REGISTER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the lab, after the same restart, the connection works immediately after this command. Run it in every instance (and every RAC node) whose services that listener serves. &lt;code&gt;lsnrctl reload&lt;/code&gt; re-reads &lt;code&gt;listener.ora&lt;/code&gt; without a stop/start, but don't count on it keeping the registrations: in the lab it sometimes kept every one and sometimes dropped them all. Follow a reload with &lt;code&gt;ALTER SYSTEM REGISTER&lt;/code&gt; too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: the service didn't come back with the database
&lt;/h2&gt;

&lt;p&gt;The PDB is open. &lt;code&gt;FREEPDB1&lt;/code&gt; connects. The application's service doesn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FREEPDB1 is READ WRITE; active services: freepdb1
localhost:1521/FREEPDB1    -&amp;gt; connected
localhost:1521/orders_app  -&amp;gt; ORA-12514: ... Service orders_app is not registered with the listener ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A service created with &lt;code&gt;DBMS_SERVICE&lt;/code&gt; and started by hand is running only until the next time the PDB closes. After a patch, a server reboot or a &lt;a href="https://uptimearchitect.com/blog/oracle-data-guard-switchover-vs-failover/" rel="noopener noreferrer"&gt;switchover&lt;/a&gt;, the PDB comes back and its default service with it, but nothing starts &lt;code&gt;orders_app&lt;/code&gt;. The database is fine. The application is down.&lt;/p&gt;

&lt;p&gt;On a single instance, start the service and save the PDB's state, so it reopens with the same services 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="k"&gt;SESSION&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;CONTAINER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FREEPDB1&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_SERVICE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;START_SERVICE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders_app'&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;PLUGGABLE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;FREEPDB1&lt;/span&gt; &lt;span class="n"&gt;SAVE&lt;/span&gt; &lt;span class="k"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lab restarts the PDB again after this, and &lt;code&gt;orders_app&lt;/code&gt; connects. Under Grid Infrastructure or RAC, define the service in Clusterware instead (&lt;code&gt;srvctl add service -db ... -pdb FREEPDB1 -service orders_app&lt;/code&gt;) and it starts with the database. With Data Guard, the same command takes &lt;code&gt;-role PRIMARY&lt;/code&gt;, so the application's service runs only where the primary is, and follows it on switchover. That last case is outside what the lab builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 4: a listener the database doesn't register with
&lt;/h2&gt;

&lt;p&gt;A new listener on port 1522, for an application tier or a firewall rule, or a listener moved off 1521. Clients pointed at it get ORA-12514, and its status says why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ lsnrctl status LISTENER2
...
The listener supports no services
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database registers only with the listeners it knows about. By default that's the local listener on port 1521. Any other address has to be in &lt;code&gt;LOCAL_LISTENER&lt;/code&gt; (or &lt;code&gt;REMOTE_LISTENER&lt;/code&gt;, for RAC and SCAN):&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;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;local_listener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s1"&gt;'(ADDRESS_LIST=(ADDRESS=(PROTOCOL=TCP)(HOST=db01)(PORT=1521))(ADDRESS=(PROTOCOL=TCP)(HOST=db01)(PORT=1522)))'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the lab, both services connect through port 1522 right after this; changing the parameter triggers a registration. (The lab uses &lt;code&gt;SCOPE = MEMORY&lt;/code&gt; so a rerun starts clean.) On a Grid Infrastructure node, the agent manages &lt;code&gt;LOCAL_LISTENER&lt;/code&gt;; register a new listener with &lt;code&gt;srvctl&lt;/code&gt; instead of setting it by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;When ORA-12514 comes in, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the full message.&lt;/strong&gt; The service name and the host and port the client used are in it (newer clients) or in the listener log (all releases).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;lsnrctl services&lt;/code&gt;&lt;/strong&gt; on that host. Is the service listed? If yes, the client has the wrong name or the wrong listener.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SELECT name FROM v$active_services&lt;/code&gt;&lt;/strong&gt; in the database (in the PDB). Is the service running? If not, start it, and make sure it starts next time (&lt;code&gt;SAVE STATE&lt;/code&gt; or &lt;code&gt;srvctl&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SHOW PARAMETER local_listener&lt;/code&gt;&lt;/strong&gt;. Does it include the address the client is using?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ALTER SYSTEM REGISTER&lt;/code&gt;&lt;/strong&gt;. Re-registers everything now. Harmless, instant, and much better than a listener restart.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restarting the listener first.&lt;/strong&gt; It refuses every service until the database re-registers, about a minute in the lab. If a restart or a reload really is needed, follow it with &lt;code&gt;ALTER SYSTEM REGISTER&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating ORA-12514 as "the database is down."&lt;/strong&gt; It's a naming or registration problem. The lab's database was up and open through every case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starting app services by hand.&lt;/strong&gt; They don't survive a PDB restart. Use &lt;code&gt;SAVE STATE&lt;/code&gt; or Clusterware, and test it by restarting the PDB before the patch weekend does it for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Giving a PDB name as a SID.&lt;/strong&gt; That's ORA-12505, not ORA-12514. PDBs are reached by service name: &lt;code&gt;/FREEPDB1&lt;/code&gt;, not &lt;code&gt;:FREEPDB1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relying on a default service.&lt;/strong&gt; The lab's container has &lt;code&gt;DEFAULT_SERVICE_LISTENER&lt;/code&gt; set, so a connect string with no service name quietly lands in the CDB root. Without that parameter it fails with ORA-12504. Neither is where the application should be; always name the service.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What does ORA-12514 TNS:listener does not currently know of service requested in connect descriptor mean?
&lt;/h3&gt;

&lt;p&gt;It means the client reached a listener, and the listener has no registered service with the name the client asked for. The listener is up and reachable; the database may be healthy too. Either the client asked for the wrong service name (a typo, a missing or extra domain, an old name after a migration), or the right service isn't registered with that listener: because the listener was just restarted, the service isn't running, or the database doesn't register with that listener. Newer clients word the same error as 'Cannot connect to database. Service X is not registered with the listener at host ... port ...'.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I fix ORA-12514?
&lt;/h3&gt;

&lt;p&gt;Run lsnrctl services on the database host and check whether the service is listed. If it is, fix the client's connect string; the listener log records the exact service name each refused client asked for. If it isn't, check v$active_services in the database: start the service if it's stopped and make it start automatically (SAVE STATE on a single instance, srvctl under Grid Infrastructure). Check that LOCAL_LISTENER includes the listener's address. Then run ALTER SYSTEM REGISTER to register everything with the listener immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do I get ORA-12514 after restarting the listener?
&lt;/h3&gt;

&lt;p&gt;Because a newly started listener knows no services. The database registers its services again on its own, which took about a minute in the companion lab, and until then every service is refused with ORA-12514. ALTER SYSTEM REGISTER in each instance makes the database register immediately. lsnrctl reload, which re-reads listener.ora without a stop/start, sometimes dropped the registrations in the lab too, so follow it with ALTER SYSTEM REGISTER as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between ORA-12514, ORA-12505, ORA-12541 and ORA-12154?
&lt;/h3&gt;

&lt;p&gt;They fail at different steps. ORA-12154 means the client couldn't resolve the alias (in tnsnames.ora or LDAP) and never reached a listener. ORA-12541 means nothing is listening at that host and port. ORA-12505 means the listener doesn't know the SID requested, often because a PDB name was given as a SID (host:1521:PDB instead of //host:1521/PDB). ORA-12514 means the listener answered but has no service by the requested name.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my PDB's application service disappear after a restart?
&lt;/h3&gt;

&lt;p&gt;A service created with DBMS_SERVICE and started manually is not started automatically when the PDB opens again. The PDB and its default service come back, so the database looks healthy, but connections to the application service fail with ORA-12514. On a single instance, start the service and run ALTER PLUGGABLE DATABASE ... SAVE STATE so the PDB reopens with the same services. Under Grid Infrastructure or RAC, create the service with srvctl add service so Clusterware starts it, with -role PRIMARY for Data Guard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does ORA-12514 mean the database is down?
&lt;/h3&gt;

&lt;p&gt;No. ORA-12514 is about registration and naming, not database health. In the companion lab the database was up and the PDB open READ WRITE in every case that produced ORA-12514. If the instance were down, a listener with only dynamic registration would also report ORA-12514, so check both: lsnrctl services for what the listener has, and the instance itself for whether it's running.&lt;/p&gt;

&lt;p&gt;ORA-12514 is a listener telling you exactly what's wrong: it's here, and it doesn't have that name. Before you restart anything, compare what the client asked for (the listener log) with what the listener has (&lt;code&gt;lsnrctl services&lt;/code&gt;), and let that point you at the client or the database. Then register, don't restart. It sits with the other Oracle Net settings worth getting right, like &lt;a href="https://uptimearchitect.com/blog/oracle-sqlnet-network-encryption/" rel="noopener noreferrer"&gt;network encryption&lt;/a&gt;, and with the cutover-weekend surprises like &lt;a href="https://uptimearchitect.com/blog/oracle-sequence-cutover-ora-00001/" rel="noopener noreferrer"&gt;the sequence that wasn't moved&lt;/a&gt;. Then prove it the way that ends the argument — with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/listener-service" rel="noopener noreferrer"&gt;listener-service lab&lt;/a&gt;, where a listener restart refuses every service for a minute and one &lt;code&gt;ALTER SYSTEM REGISTER&lt;/code&gt; brings them all back.&lt;/p&gt;




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

</description>
      <category>oracle</category>
      <category>database</category>
      <category>devops</category>
      <category>networking</category>
    </item>
    <item>
      <title>ORA-01652: Unable to Extend Temp Segment. Don't Just Add Space.</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:00:23 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/ora-01652-unable-to-extend-temp-segment-dont-just-add-space-njm</link>
      <guid>https://dev.to/uptimearchitect/ora-01652-unable-to-extend-temp-segment-dont-just-add-space-njm</guid>
      <description>&lt;p&gt;The open-orders report has run every morning for a year. Today it fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ORA-01652: unable to extend temp segment by 128 in tablespace TEMP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix everyone knows is one line: add a tempfile. It takes a minute, it's online, and the next run fails again, a little later. So someone adds a bigger tempfile, turns on autoextend, and the report fails a third time, after filling the disk.&lt;/p&gt;

&lt;p&gt;ORA-01652 says TEMP ran out. It doesn't say &lt;em&gt;why&lt;/em&gt;. Sometimes TEMP really is too small for legitimate work. Often, one query is asking for far more TEMP than any database could give it, and the error is the only thing standing between you and a wrong answer. This post covers what TEMP is for, how to tell the two cases apart, how to find the query, and why TEMP looks full even when it isn't. It ends with a lab where giving TEMP &lt;strong&gt;ten times the space buys ten times the runtime and the same error&lt;/strong&gt;, while restoring one join predicate makes the same report need &lt;strong&gt;no TEMP at all&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What TEMP is for
&lt;/h2&gt;

&lt;p&gt;Sorts, hash joins, &lt;code&gt;DISTINCT&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, analytic functions and index builds all need a work area. Oracle gives each one memory from the PGA first. When the work area outgrows its share of memory, the operation &lt;strong&gt;spills&lt;/strong&gt; to the temporary tablespace: it writes partial results to TEMP, then reads them back to finish.&lt;/p&gt;

&lt;p&gt;Spilling is normal; a big nightly batch or an index rebuild is supposed to use TEMP. ORA-01652 is raised when a spilling operation asks for another extent and the temporary tablespace has none left to give, and can't grow. The statement fails and its TEMP is released.&lt;/p&gt;

&lt;p&gt;So the question is never just "is TEMP big enough?" It's "is this operation's demand reasonable?" A sort of 200,000 rows that needs 40 MB of TEMP is reasonable. A sort that needs 8 GB to produce 50,000 rows is a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The classic runaway
&lt;/h2&gt;

&lt;p&gt;Here is the report as it shipped:&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;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&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;c&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A line went missing in an edit: &lt;code&gt;AND o.customer_id = c.customer_id&lt;/code&gt;. Without it, there is no join condition, so Oracle pairs &lt;strong&gt;every&lt;/strong&gt; open order with &lt;strong&gt;every&lt;/strong&gt; customer, a Cartesian product. The report suddenly showed each order thousands of times, so someone added &lt;code&gt;DISTINCT&lt;/code&gt; to "remove the duplicates."&lt;/p&gt;

&lt;p&gt;Now the database has to deduplicate the whole product. With 50,000 open orders and 2,000 customers, that's 100 million rows into a &lt;code&gt;HASH UNIQUE&lt;/code&gt;, far beyond any PGA work area. It spills, and keeps spilling until TEMP is gone.&lt;/p&gt;

&lt;p&gt;And here's the part that makes adding space the wrong fix: the rows aren't actually duplicates. Each customer name is different, so every one of the 100 million combinations is distinct. &lt;strong&gt;Given enough TEMP, the report would finish, and return 100 million rows instead of 50,000.&lt;/strong&gt; ORA-01652 is the only thing stopping a wrong report from shipping.&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%3AeNpFkVtLAzEUhP_KEHyosMUL6EMRZbEXBcXWVl9ckezmbDeSTUqS1Urb_24SVnwMmfPNzDk7VhlBbARWK_NdNdx6rMaFBiZvBXt6zoenZ5cX56Or0p5cd5qXiuANaOtJC3hqN3C0bkn7gr1jOLzGIsxNZfxsCM7zoAm_aX6wXDzgfpzh9Wg1eZwvJ7OPl2U-m2TIl3fHARB9F4ky3xXs3kF6hyiFoJYHpiXuTEqRgLWxycaa76iFa0ynosp3Vt8U7BCB8wjcF-yH3AilXKPkvmoyhIy0RdlJJbJEa40lVEZXnbUhMjpH1hVsjzxUyoWA2_CKEDx5al7LPsbAaCU1_TX4M9RmhNuwUHKSa3waqXsf6ZzUa2wsCVmFBWUhksBGcR3dpmmB21QsLOw_Wm-v1Q9a80UuKWouVWept64Udw451saI__c08lkG1pJtuRTh3DsWhtt0eEE175Rnh8MvRMCtJg" 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%3AeNpFkVtLAzEUhP_KEHyosMUL6EMRZbEXBcXWVl9ckezmbDeSTUqS1Urb_24SVnwMmfPNzDk7VhlBbARWK_NdNdx6rMaFBiZvBXt6zoenZ5cX56Or0p5cd5qXiuANaOtJC3hqN3C0bkn7gr1jOLzGIsxNZfxsCM7zoAm_aX6wXDzgfpzh9Wg1eZwvJ7OPl2U-m2TIl3fHARB9F4ky3xXs3kF6hyiFoJYHpiXuTEqRgLWxycaa76iFa0ynosp3Vt8U7BCB8wjcF-yH3AilXKPkvmoyhIy0RdlJJbJEa40lVEZXnbUhMjpH1hVsjzxUyoWA2_CKEDx5al7LPsbAaCU1_TX4M9RmhNuwUHKSa3waqXsf6ZzUa2wsCVmFBWUhksBGcR3dpmmB21QsLOw_Wm-v1Q9a80UuKWouVWept64Udw451saI__c08lkG1pJtuRTh3DsWhtt0eEE175Rnh8MvRMCtJg" alt="ORA-01652 means a spilling sort or hash asked TEMP for more space than it had. Before addi" width="554" height="870"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ORA-01652 means a spilling sort or hash asked TEMP for more space than it had. Before adding space, check whether the demand is reasonable. A legitimate big operation needs more TEMP; a runaway query, like a Cartesian join behind a DISTINCT, will fill any amount of TEMP and needs its SQL fixed.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it
&lt;/h2&gt;

&lt;p&gt;The lab runs Oracle Database Free in a container. It creates a &lt;strong&gt;32 MB&lt;/strong&gt; temporary tablespace that cannot autoextend and assigns it to an application user. That user owns &lt;code&gt;ORDERS&lt;/code&gt; (200,000 rows, 50,000 of them &lt;code&gt;OPEN&lt;/code&gt;) and &lt;code&gt;CUSTOMERS&lt;/code&gt; (2,000 rows). Then it runs the broken report three ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TEMP            report                     result            elapsed   TEMP used
32 MB           join predicate missing     ORA-01652          1.2 s       31 MB
320 MB (10x)    join predicate missing     ORA-01652 again   10.7 s      318 MB
32 MB           join predicate restored    50,000 rows        0.1 s        0 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten times the space bought ten times the runtime, then the same error. (Elapsed times depend on the hardware; the lab checks the outcomes, not the clock.) A hundred times the space would buy a hundred times the runtime. With the join predicate back, and &lt;code&gt;DISTINCT&lt;/code&gt; left in place to show that the join alone is the fix, the report returns its 50,000 rows in a tenth of a second without touching TEMP.&lt;/p&gt;

&lt;p&gt;The plan says so before anything runs. &lt;code&gt;EXPLAIN PLAN&lt;/code&gt; on the broken report:&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      | Rows  | Bytes |TempSpc|
---------------------------------------------------------------------
|   3 |    HASH UNIQUE          |           |  200M |  6484M|  8445M|
|   4 |     MERGE JOIN CARTESIAN|           |  200M |  6484M|       |
|   5 |      TABLE ACCESS FULL  | ORDERS    |  100K |  1660K|       |
|   6 |      BUFFER SORT        |           |  2000 | 34000 |       |
|   7 |       TABLE ACCESS FULL | CUSTOMERS |  2000 | 34000 |       |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MERGE JOIN CARTESIAN&lt;/code&gt; and a &lt;code&gt;TempSpc&lt;/code&gt; estimate of &lt;strong&gt;8,445M&lt;/strong&gt;, about 8 GB of TEMP, for a report that should return 50,000 rows. (The optimizer estimates twice the real row count because, without a histogram, it assumes half the orders are &lt;code&gt;OPEN&lt;/code&gt;. That doesn't change the conclusion.) &lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;Reading an execution plan&lt;/a&gt; for &lt;code&gt;TempSpc&lt;/code&gt; and Cartesian joins takes seconds, and it would have caught this before the report ever ran.&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/temp-space" rel="noopener noreferrer"&gt;temp-space lab&lt;/a&gt; runs the broken report on 32 MB of TEMP and &lt;strong&gt;asserts&lt;/strong&gt; ORA-01652. It asserts the Cartesian join in the plan, then adds a 288 MB tempfile online and asserts the &lt;strong&gt;same&lt;/strong&gt; error again, after catching the report live in &lt;code&gt;V$TEMPSEG_USAGE&lt;/code&gt;. Finally it drops the extra tempfile and asserts that the corrected report returns 50,000 rows using no TEMP. If any of that doesn't reproduce, the run fails. Proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How to find the query
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read the whole message.&lt;/strong&gt; Recent releases put the culprit in the error itself. The lab's Oracle Database Free (26ai) says &lt;em&gt;grow&lt;/em&gt; rather than &lt;em&gt;extend&lt;/em&gt;, and names the SQL ID and how much TEMP the session held:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ORA-01652: unable to grow temp segment in tablespace TEMP_SMALL by 1MB during operation
with SQL ID : 249gkzhhu3cdk, temp space used by session : 31 (MB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same line goes to the alert log. On 19c the message names only the tablespace, so you have to catch the statement another way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live: &lt;code&gt;V$TEMPSEG_USAGE&lt;/code&gt;.&lt;/strong&gt; While the statement is running, this view shows who holds TEMP and for which SQL:&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql_id_tempseg&lt;/span&gt;&lt;span class="p"&gt;,&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;segtype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&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;blocks&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;temp_mb&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;tempseg_usage&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt;   &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;session&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;saddr&lt;/span&gt; &lt;span class="o"&gt;=&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;session_addr&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt;   &lt;span class="n"&gt;dba_tablespaces&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tablespace_name&lt;/span&gt; &lt;span class="o"&gt;=&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;tablespace&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;SQL_ID_TEMPSEG&lt;/code&gt;, not &lt;code&gt;SQL_ID&lt;/code&gt;. In the lab, watching the report from a second session, &lt;code&gt;SQL_ID&lt;/code&gt; pointed at SQL*Plus's own login query (&lt;code&gt;SELECT DECODE(USER, 'XS$NULL', ...&lt;/code&gt;), while &lt;code&gt;SQL_ID_TEMPSEG&lt;/code&gt; was the report that was actually holding the TEMP. The other catch: once the statement fails, its TEMP is released and the row is gone. In the lab, &lt;code&gt;V$TEMPSEG_USAGE&lt;/code&gt; is empty a second after the error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After the fact: ASH.&lt;/strong&gt; Active Session History samples every active session once a second, and each sample records &lt;code&gt;TEMP_SPACE_ALLOCATED&lt;/code&gt;. The statement that filled TEMP stands out:&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_id&lt;/span&gt;&lt;span class="p"&gt;,&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;AS&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temp_space_allocated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;peak_temp_mb&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;active_session_history&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;sample_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;'30'&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;temp_space_allocated&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sql_id&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;peak_temp_mb&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the lab, ASH caught &lt;code&gt;249gkzhhu3cdk&lt;/code&gt; peaking at over 300 MB of TEMP. For older incidents, &lt;code&gt;DBA_HIST_ACTIVE_SESS_HISTORY&lt;/code&gt; has the same column, and an &lt;a href="https://uptimearchitect.com/blog/how-to-read-an-awr-report/" rel="noopener noreferrer"&gt;AWR report&lt;/a&gt; shows the top SQL for the window. (ASH and AWR are part of the Diagnostics Pack on Enterprise Edition.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then look at its plan.&lt;/strong&gt; With the SQL ID, &lt;code&gt;DBMS_XPLAN.DISPLAY_CURSOR&lt;/code&gt; (if the cursor is still in the shared pool) or &lt;code&gt;EXPLAIN PLAN&lt;/code&gt; on the statement text tells you whether the demand makes sense. Compare the &lt;code&gt;TempSpc&lt;/code&gt; and row estimates with what the statement should return.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why TEMP looks full when it isn't
&lt;/h2&gt;

&lt;p&gt;Right after the failure, the lab looks at the 32 MB tablespace through four views:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V$TEMPSEG_USAGE        0 rows        the session that caused it is gone
V$TEMP_SPACE_HEADER    32 MB used, 0 MB free
V$SORT_SEGMENT         0 MB in use
DBA_TEMP_FREE_SPACE    31 MB free of 32 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Oracle doesn't hand TEMP extents back to the files when a sort finishes. It keeps them allocated in the sort segment and gives them to the next operation that needs them, which is faster than allocating fresh ones. &lt;code&gt;V$TEMP_SPACE_HEADER&lt;/code&gt; keeps counting those extents as used after the sort ends, so a monitoring check built on it reports TEMP at 100% all day after the first big sort. That isn't a leak, and it isn't a reason to add space. &lt;code&gt;V$SORT_SEGMENT&lt;/code&gt; (&lt;code&gt;USED_BLOCKS&lt;/code&gt;) and &lt;code&gt;DBA_TEMP_FREE_SPACE&lt;/code&gt; show what's actually free. Alert on those.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;If the SQL is wrong, fix the SQL.&lt;/strong&gt; A missing join predicate, a &lt;code&gt;DISTINCT&lt;/code&gt; hiding duplicates, a join on the wrong column, a plan that hash-joins two huge row sources because statistics are stale: these need a code or statistics fix. TEMP is just where the damage shows up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the work is legitimate, add space online.&lt;/strong&gt; Big index builds, a nightly aggregation, and more concurrent users than TEMP was sized for are real reasons TEMP is too small:&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;-- Smallfile temporary tablespace: add a tempfile&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;TABLESPACE&lt;/span&gt; &lt;span class="k"&gt;temp&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="n"&gt;TEMPFILE&lt;/span&gt; &lt;span class="s1"&gt;'/u01/oradata/PROD/temp02.dbf'&lt;/span&gt; &lt;span class="k"&gt;SIZE&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="k"&gt;G&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Bigfile temporary tablespace (one file only): resize it&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;TEMPFILE&lt;/span&gt; &lt;span class="s1"&gt;'/u01/oradata/PROD/temp01.dbf'&lt;/span&gt; &lt;span class="n"&gt;RESIZE&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="k"&gt;G&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are online, and so is &lt;code&gt;ALTER TABLESPACE ... DROP TEMPFILE&lt;/code&gt; once nothing is using the file; the lab adds a 288 MB tempfile and drops it again without a restart. A bigfile tablespace holds exactly one file, so &lt;code&gt;ADD TEMPFILE&lt;/code&gt; fails with ORA-32771 there. The lab's 26ai database creates new tablespaces as bigfile unless told otherwise; check &lt;code&gt;DEFAULT_TBS_TYPE&lt;/code&gt; in &lt;code&gt;DATABASE_PROPERTIES&lt;/code&gt; on yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contain the damage.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cap autoextend.&lt;/strong&gt; An autoextending tempfile without &lt;code&gt;MAXSIZE&lt;/code&gt; doesn't fix a runaway query. It lets the query fill the filesystem instead of the tablespace, and then everything on that filesystem fails too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate temporary tablespaces.&lt;/strong&gt; Give a reporting or batch user its own temporary tablespace (the lab's user has &lt;code&gt;TEMP_SMALL&lt;/code&gt;). Then one application's runaway query can't starve every other session's sorts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size from evidence.&lt;/strong&gt; The peak of &lt;code&gt;DBA_HIST_ACTIVE_SESS_HISTORY.TEMP_SPACE_ALLOCATED&lt;/code&gt; summed across sessions, over a few busy weeks, tells you what your legitimate workload actually needs.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adding space first, asking later.&lt;/strong&gt; It's the right fix maybe half the time. Check the statement's plan before you add the second tempfile, not after the third.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turning on unlimited autoextend.&lt;/strong&gt; It converts a failed report into a full filesystem. Use &lt;code&gt;MAXSIZE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting &lt;code&gt;V$TEMP_SPACE_HEADER&lt;/code&gt;.&lt;/strong&gt; It shows extents the sort segment has cached, not space in use. A TEMP that "is always 100% full" is usually fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Looking for the culprit too late, or in the wrong column.&lt;/strong&gt; &lt;code&gt;V$TEMPSEG_USAGE&lt;/code&gt; empties the moment the statement fails; read the full error text, or go to ASH. While it's running, trust &lt;code&gt;SQL_ID_TEMPSEG&lt;/code&gt;, not &lt;code&gt;SQL_ID&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hiding duplicates with &lt;code&gt;DISTINCT&lt;/code&gt;.&lt;/strong&gt; Unexpected duplicates are a symptom of a wrong join. &lt;code&gt;DISTINCT&lt;/code&gt; makes the report look right on small data and turns into a TEMP bomb on real data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confusing it with a permanent tablespace.&lt;/strong&gt; &lt;code&gt;ORA-01652 ... in tablespace USERS&lt;/code&gt; (or any non-temporary tablespace) is a different problem: a &lt;code&gt;CREATE TABLE AS SELECT&lt;/code&gt;, index build or &lt;code&gt;MOVE&lt;/code&gt; ran out of room while building its new segment. The tablespace named in the message tells you which case you have.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What does ORA-01652 unable to extend temp segment mean?
&lt;/h3&gt;

&lt;p&gt;It means an operation that spills to disk, such as a sort, hash join, DISTINCT, GROUP BY or index build, asked the temporary tablespace for another extent and none was available, and the tablespace could not grow. The statement fails and its temporary space is released. The error says that TEMP ran out, not why: either the temporary tablespace is genuinely too small for legitimate work, or one statement is demanding far more TEMP than it should, often because of a missing join predicate or a bad plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I just add a tempfile to fix ORA-01652?
&lt;/h3&gt;

&lt;p&gt;Only after checking the statement that caused it. If the work is legitimate, such as a large index build, a nightly batch or more concurrent sessions, adding a tempfile (or resizing the tempfile of a bigfile tablespace) is the right fix and it is online. If the statement is a runaway, such as a Cartesian join behind a DISTINCT, more space only moves the failure. In the companion lab, giving TEMP ten times the space made the same report fail again after ten times the runtime, while restoring one join predicate made it return 50,000 rows using no TEMP.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I find which query caused ORA-01652?
&lt;/h3&gt;

&lt;p&gt;Recent Oracle releases include the SQL ID and the session's temp usage in the error message and the alert log. On 19c, which names only the tablespace, query V$TEMPSEG_USAGE joined to V$SESSION while the statement is running (use its SQL_ID_TEMPSEG column, which identifies the statement that owns the temp segment; SQL_ID can show a different statement the session parsed), or look in Active Session History afterwards: V$ACTIVE_SESSION_HISTORY and DBA_HIST_ACTIVE_SESS_HISTORY record TEMP_SPACE_ALLOCATED for every sample, so grouping by SQL_ID and taking the maximum shows the statement that filled TEMP. Then check its execution plan for a Cartesian join or a TempSpc estimate far larger than the result should need.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my temporary tablespace always show 100% used?
&lt;/h3&gt;

&lt;p&gt;Because Oracle keeps temporary extents allocated in the sort segment after a sort finishes and reuses them for the next operation, rather than freeing them back to the tempfiles. V$TEMP_SPACE_HEADER counts those cached extents as used, so it can show TEMP as full long after the sorts that used it have finished. V$SORT_SEGMENT (USED_BLOCKS versus FREE_BLOCKS) and DBA_TEMP_FREE_SPACE show the space that is actually free, and are the right views to monitor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I add or drop a tempfile without downtime?
&lt;/h3&gt;

&lt;p&gt;Yes. ALTER TABLESPACE temp ADD TEMPFILE ... SIZE ... is online, and so is ALTER TABLESPACE temp DROP TEMPFILE ... once no session is using the file. A bigfile temporary tablespace can hold only one tempfile, so adding a file fails with ORA-32771; resize the existing one with ALTER DATABASE TEMPFILE ... RESIZE instead. DATABASE_PROPERTIES.DEFAULT_TBS_TYPE shows whether new tablespaces default to bigfile or smallfile on your database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is ORA-01652 on a permanent tablespace the same problem?
&lt;/h3&gt;

&lt;p&gt;No. When the message names a permanent tablespace such as USERS rather than a temporary one, the space was needed to build a new permanent segment: a CREATE TABLE AS SELECT, an index build or rebuild, or an ALTER TABLE MOVE, which create their new segment as temporary extents in the target tablespace until they finish. The fix is space in that tablespace, or a smaller operation, not a bigger TEMP.&lt;/p&gt;

&lt;p&gt;ORA-01652 is easy to make go away for a day and hard to make go away for good, because the obvious fix treats the symptom. Before you add space, read the whole error, find the statement, and look at its plan. If the demand is legitimate, add a tempfile online and cap its growth. If it's a Cartesian join behind a &lt;code&gt;DISTINCT&lt;/code&gt;, no amount of TEMP will help, and enough of it would let a wrong report finish. It belongs with the other errors that freeze or fail work for a reason the message doesn't spell out, like &lt;a href="https://uptimearchitect.com/blog/oracle-ora-01555-snapshot-too-old/" rel="noopener noreferrer"&gt;ORA-01555&lt;/a&gt; and &lt;a href="https://uptimearchitect.com/blog/oracle-ora-00257-archiver-stuck/" rel="noopener noreferrer"&gt;ORA-00257&lt;/a&gt;. Then prove it the way that ends the argument — with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/temp-space" rel="noopener noreferrer"&gt;temp-space lab&lt;/a&gt;, where ten times the TEMP buys ten times the runtime and the same error, and one restored join needs none.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-ora-01652-temp-space/" 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>I couldn't even read one row. The session that locked it was gone. So I built a ghost hunter for Oracle</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Wed, 28 Oct 2026 01:52:57 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/i-couldnt-even-read-one-row-the-session-that-locked-it-was-gone-so-i-built-a-ghost-hunter-for-a99</link>
      <guid>https://dev.to/uptimearchitect/i-couldnt-even-read-one-row-the-session-that-locked-it-was-gone-so-i-built-a-ghost-hunter-for-a99</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&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;select&lt;/span&gt; &lt;span class="n"&gt;amt&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gh_ledger&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="n"&gt;ORA&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;01591&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;lock&lt;/span&gt; &lt;span class="n"&gt;held&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;doubt&lt;/span&gt; &lt;span class="n"&gt;distributed&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;71483&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Not an update. A plain &lt;code&gt;select&lt;/code&gt;. Oracle wouldn't even let me &lt;em&gt;look&lt;/em&gt; at the row.&lt;/p&gt;

&lt;p&gt;Two minutes earlier, a transfer of 40 between two of my lab databases had crashed halfway through its commit. (Oracle commits across two databases in two steps: both sides promise, then both commit. This one died between the steps.) I crashed it on purpose, with a test hook Oracle ships for exactly this (&lt;code&gt;COMMIT COMMENT 'ORA-2PC-CRASH-TEST-7'&lt;/code&gt;), because I wanted to see what a DBA is left with afterwards.&lt;/p&gt;

&lt;p&gt;One side committed. The other had promised to commit, and then the session that made the promise was gone. Until somebody decides how that story ends, the row stays locked for everyone, and no session holds the lock, so there's nothing to kill. Oracle's recovery process would normally settle it once both databases can talk again. In my lab I switched that off, so the row waits. Nobody is coming back for it.&lt;/p&gt;

&lt;p&gt;DBAs have a quiet name for things like this. Ghosts. For this week's theme I took the word literally: things that are still in the database after whatever owned them is gone.&lt;/p&gt;

&lt;p&gt;Día de los Muertos isn't about getting rid of the dead. It's about saying who they were. So nothing here is removed quietly: every ghost gets its name, its date and an epitaph written into the report, and the report stays on disk after the ghost is laid to rest.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ghost Hunt&lt;/strong&gt; finds what's still haunting your Oracle databases, and lays it to rest only when you say so.&lt;/p&gt;

&lt;p&gt;Six kinds so far:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🕯️ a session whose user went home with a transaction open, while someone else waits on it;&lt;/li&gt;
&lt;li&gt;⛓️ an in-doubt distributed transaction, like the one above;&lt;/li&gt;
&lt;li&gt;📜 a database file on disk that the database stopped using;&lt;/li&gt;
&lt;li&gt;⚰️ a dropped table still holding its space in the recycle bin;&lt;/li&gt;
&lt;li&gt;🪦 an object left invalid by something it depended on;&lt;/li&gt;
&lt;li&gt;🗝️ a contractor's locked account whose nightly job is still enabled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SQL finds every ghost. &lt;strong&gt;Gemma 4&lt;/strong&gt;, running locally on Ollama, writes the field notes: two lines on top, and an epitaph for each ghost. A &lt;strong&gt;Temporal&lt;/strong&gt; workflow then waits, for up to twelve hours, for a person to approve each exorcism. Only then does it touch anything, and afterwards it hunts again to prove the ghost is gone.&lt;/p&gt;

&lt;p&gt;This is the in-doubt ghost from the opening, as the report shows it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### 2. ⛓️ Distributed transaction 5.30.71483 is prepared and nobody is coming back for it
lab-se2 · ORCLPDB1. Since 2026-10-27 23:48.

Here lies 5.30.71483, a prepared distributed transaction from 23:48.
It waits for a master that will never return.

The other side: lab-26ai/FREEPDB1 committed. Decision: commit force: the other side committed.

Exorcism, if you approve it:
commit force '5.30.71483';
begin dbms_transaction.purge_lost_db_entry('5.30.71483'); commit; end;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The epitaph is Gemma's. Everything else, including &lt;strong&gt;the decision&lt;/strong&gt;, is code. Ghost Hunt reads both databases, matches the two halves of the transaction by its global id, and sees that the other side committed. So the only correct ending is to force this side to commit too. If no side has an outcome written down, it refuses to guess and tells you what to look up.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/TWNPsXcCrMk" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The video is one real run, start to finish:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the row nobody can read;&lt;/li&gt;
&lt;li&gt;the hunt and its report;&lt;/li&gt;
&lt;li&gt;I &lt;code&gt;kill -9&lt;/code&gt; the worker while the hunt waits for me;&lt;/li&gt;
&lt;li&gt;I approve the exorcism anyway, while no worker is running;&lt;/li&gt;
&lt;li&gt;I start the worker again. Temporal picks the workflow up where it was and lays three ghosts to rest: the session blocking someone, the prepared side of the transfer, and the committed side's leftover entry. Then it hunts again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then the row reads &lt;code&gt;60&lt;/code&gt;, which is 100 minus the 40 that left. The other database reads &lt;code&gt;140&lt;/code&gt;. The money that was in limbo is accounted for on both sides.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/pyaroslav" rel="noopener noreferrer"&gt;
        pyaroslav
      &lt;/a&gt; / &lt;a href="https://github.com/pyaroslav/ghost-hunt" rel="noopener noreferrer"&gt;
        ghost-hunt
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Finds the ghosts in your Oracle databases and lays them to rest only when you approve. Gemma + Temporal + Sentry agent tracing.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Ghost Hunt&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/pyaroslav/ghost-hunt/actions/workflows/tests.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/pyaroslav/ghost-hunt/actions/workflows/tests.yml/badge.svg" alt="tests"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e"&gt;&lt;img src="https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e" alt="license: MIT"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ghost Hunt finds what's still haunting your Oracle databases, and lays it to rest only when you say so.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A ghost is something still in the database after whatever owned it is gone: a session whose user went home
with a transaction open, a distributed transaction that crashed halfway and now holds a row nobody can even
read, a control file the database stopped using two weeks ago, a dropped table still holding its space, a
contractor's locked account whose nightly job still runs.&lt;/p&gt;
&lt;p&gt;SQL finds every ghost. Gemma, running locally, writes the field notes. A Temporal workflow waits, for hours if
need be, for a person to approve each exorcism, and then hunts again to prove the ghost is gone.&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;### 2. ⛓️ Distributed transaction 10.9.199680 is prepared and nobody is coming back for it
`lab-se2` · `ORCLPDB1`. Since 2026-10-27 22:37
_Here lies 10.9.199680, a prepared distributed&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/pyaroslav/ghost-hunt" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  The Spirit of It: the one ghost I didn't plant
&lt;/h2&gt;

&lt;p&gt;To have something to hunt, I planted ghosts in two lab databases running in Docker: Oracle 26ai Free and 12.2 Standard Edition 2. The first time I ran Ghost Hunt, it found one I hadn't planted. Here it is in the demo run's report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### 5. 📜 control02.ctl (18 MB) is on disk but the database stopped using it
lab-se2 · CDB$ROOT. Since 2026-10-13 16:08.

Here lies control02.ctl, an 18 MB file from 2026-10-13.
It rests on the disk, though the database no longer needs it.

The hunter won't touch this one. Move it aside, keep it a week, then delete it.
Never delete a file the database still lists.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I went back through that database's alert log. On October 13 at 16:07, while the container was still creating the database, its setup script ran this:&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;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;control_files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'/opt/oracle/oradata/ORCLCDB/control01.ctl'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From that restart on, the database had &lt;strong&gt;one&lt;/strong&gt; control file. The second copy, the one that exists in case the first is lost, was last written a minute later. It has sat on the disk for two weeks, looking exactly like a live control file: a spare kept faithfully for a database that stopped counting on it. It's the same database whose crash started my &lt;a href="https://dev.to/uptimearchitect/my-database-died-on-tuesday-i-found-out-on-sunday-so-now-a-local-model-reads-my-oracle-logs-while-4oma"&gt;Week 3 entry&lt;/a&gt;, and I'd looked at it every day since without noticing.&lt;/p&gt;

&lt;p&gt;Ghost Hunt doesn't touch files. Some ghosts you bury yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. SQL finds the ghosts, with an account that can only look
&lt;/h3&gt;

&lt;p&gt;The hunter connects as &lt;code&gt;c##ghost&lt;/code&gt;, a common user with &lt;code&gt;CREATE SESSION&lt;/code&gt; and &lt;code&gt;SELECT ANY DICTIONARY&lt;/code&gt;, and nothing else. There's one query per kind of ghost, run in each PDB (each database inside the container database). For example, a session that is idle, has an open transaction, and has others waiting on 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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;serial&lt;/span&gt;&lt;span class="o"&gt;#&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_call_et&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used_urec&lt;/span&gt;&lt;span class="p"&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;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;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;session&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blocking_session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;blocked&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;session&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ses_addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;saddr&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;s&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;=&lt;/span&gt; &lt;span class="s1"&gt;'USER'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&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;'INACTIVE'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_call_et&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The queries never select &lt;code&gt;machine&lt;/code&gt;, &lt;code&gt;osuser&lt;/code&gt; or SQL text (on my lab, &lt;code&gt;machine&lt;/code&gt; is my real hostname). Files are compared by listing the data directory (through &lt;code&gt;docker exec&lt;/code&gt;) against &lt;code&gt;v$datafile&lt;/code&gt;, &lt;code&gt;v$tempfile&lt;/code&gt;, &lt;code&gt;v$logfile&lt;/code&gt; and &lt;code&gt;v$controlfile&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Code decides; the model doesn't
&lt;/h3&gt;

&lt;p&gt;Ghosts are numbered by harm. Whatever is blocking someone right now comes first: the session others wait on, the row nobody can read. The in-doubt decision follows only an outcome that's already written down somewhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if a side committed, force the prepared side to commit;&lt;/li&gt;
&lt;li&gt;if a side was already rolled back, force a rollback;&lt;/li&gt;
&lt;li&gt;otherwise, don't decide at all. The database that decides (the commit point) never sits in &lt;code&gt;prepared&lt;/code&gt;. So if every side the hunter can see is prepared, the decider isn't in the hunt, and any choice would be a guess.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The settled side's entry is the only written record of how the transaction ended, so it's purged only &lt;em&gt;after&lt;/em&gt; its prepared partner has been forced, in the same run. Approve the committed side (#3 in the demo) without the prepared one (#2) and the hunter refuses: "approve #2 with it, so its partner is forced first."&lt;/p&gt;

&lt;p&gt;Only two kinds of ghost can be exorcised by the hunter, because only for those can the right move be read off the database: a session that &lt;strong&gt;someone is waiting on&lt;/strong&gt;, and an in-doubt transaction with a written outcome. A session that's idle with an open transaction but blocks nobody gets a note for its owner, not a kill. Everything else gets the SQL and a sentence, for a person to run.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Hours later, is it still the same ghost?
&lt;/h3&gt;

&lt;p&gt;The approval can come six hours after the hunt. By then the user who went home may be back, may have committed, and may be halfway through something new in the very same session. So right before anything runs, the exorcist checks the ghost again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;for a session: the same sid and serial# (the session's number and which incarnation of it), the same &lt;strong&gt;transaction id&lt;/strong&gt;, still idle, still blocking someone;&lt;/li&gt;
&lt;li&gt;for an in-doubt transaction: the same entry, still in the same state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If anything changed, nothing is run, and the report says "not touched: it changed since the hunt, so the approval no longer applies." Afterwards the databases are hunted again. If one can't be reached for that check, the report says the exorcism ran but couldn't be verified. It doesn't claim success it didn't see.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Gemma writes the words, and every line is checked
&lt;/h3&gt;

&lt;p&gt;Gemma gets each ghost's name, a one-sentence description, the date, and the decision. It never sees SQL. It returns JSON: two summary lines and one epitaph per ghost. The prompt asks for "an old caretaker who has buried many databases: dry, kind, a little spooky."&lt;/p&gt;

&lt;p&gt;Every line is checked before it goes into the report:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the epitaph must name its own ghost;&lt;/li&gt;
&lt;li&gt;every number in it must appear in that ghost's facts;&lt;/li&gt;
&lt;li&gt;every identifier (anything with an underscore or a dot, like &lt;code&gt;GH_AUDIT_2019&lt;/code&gt; or &lt;code&gt;LAB.GH_RATES_TODAY&lt;/code&gt;) must appear in that ghost's facts;&lt;/li&gt;
&lt;li&gt;the summary must name both databases, and its second line must name the ghost the &lt;em&gt;code&lt;/em&gt; ranked first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A line that fails gets one retry. Then a plain sentence from the facts replaces it, and the report says so. This is the pattern from my Night Shift entry, applied to every line instead of two.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Temporal waits, and survives
&lt;/h3&gt;

&lt;p&gt;The workflow is &lt;code&gt;hunt (one per database) → examine → write_story → deliver → wait → exorcise → hunt again → deliver&lt;/code&gt;. The interesting step is the wait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_condition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approval&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                              &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_hours&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An approval is a Temporal signal, so the server keeps it even while no worker is running. That's exactly what the demo does: the worker is killed during the wait, the approval arrives anyway, and the restarted worker carries on from the wait. If nobody answers in twelve hours, the workflow ends and touches nothing.&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%2Fcv6cozobjwhx81pmf6x9.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%2Fcv6cozobjwhx81pmf6x9.png" alt="Temporal timeline: two hunts, Gemma, the approval timer, the approve signal, three exorcisms, two hunts to check" width="798" height="180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demo run's whole event history is in the repo, &lt;a href="https://github.com/pyaroslav/ghost-hunt/blob/main/results/demo-history.json" rel="noopener noreferrer"&gt;&lt;code&gt;results/demo-history.json&lt;/code&gt;&lt;/a&gt;. The hunt and the report ran on worker &lt;code&gt;3143315&lt;/code&gt;. The three exorcisms and the second hunt ran on worker &lt;code&gt;3144782&lt;/code&gt;, the one I started after the kill.&lt;/p&gt;

&lt;p&gt;The exorcist is a second account, &lt;code&gt;c##exorcist&lt;/code&gt;, with just enough to kill a session and force or purge a transaction (the exact grants are in the README). That includes &lt;code&gt;ALTER SYSTEM&lt;/code&gt;, which is not a small privilege, and exactly why the exorcist isn't the hunter. I found one of the grants the hard way: on 26ai the purge fails with &lt;code&gt;ORA-41900: missing DELETE privilege on "SYS"."PENDING_TRANS$"&lt;/code&gt;, which at least says exactly what it wants.&lt;/p&gt;

&lt;p&gt;The tests run the whole workflow on Temporal's time-skipping test server, in CI, without a database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;only approved ghosts are touched;&lt;/li&gt;
&lt;li&gt;a ghost that changed since the hunt is left alone;&lt;/li&gt;
&lt;li&gt;twelve hours with no answer touch nothing. That test passes in under a second.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four more cover the purge order, an unreachable database, a check that can't run, and the model being down.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Sentry sees the shape, not the ghosts
&lt;/h3&gt;

&lt;p&gt;Every activity that touches a database or the model is a Sentry &lt;code&gt;gen_ai.invoke_agent&lt;/code&gt; transaction, with &lt;code&gt;execute_tool&lt;/code&gt; spans for the hunts and exorcisms and a &lt;code&gt;chat&lt;/code&gt; span for Gemma. Each one carries a trace id derived from the workflow id, so a hunt that waits, loses its worker and resumes is still &lt;strong&gt;one trace&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feblbm132o31q9eif4r69.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%2Feblbm132o31q9eif4r69.png" alt="The hunt in Sentry: one trace, eight activities, across the worker restart" width="800" height="162"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What leaves the machine: the model name, token counts, timings, how many ghosts of each kind, and errors cut down to their &lt;code&gt;ORA-&lt;/code&gt; code. No SQL, no object or user names, no prompt, no answer. (Sentry's servers see the IP address an event comes from, like any web server. Left alone, Sentry turns it into a city. So Ghost Hunt sends &lt;code&gt;0.0.0.0&lt;/code&gt; as the user's address, and the project has "Prevent Storing of IP Addresses" on.) The span says it plainly:&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%2F0f6bpg4enebheskuyodq.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%2F0f6bpg4enebheskuyodq.png" alt="Sentry span for the Gemma call: gemma4:26b, 967 in + 425 out tokens, &amp;quot;No input for this span&amp;quot;" width="800" height="301"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I didn't get that right the first time. The first trace I opened had a &lt;code&gt;subprocess&lt;/code&gt; span I never wrote. Sentry instruments the standard library by default, and its description was the full command line, &lt;code&gt;docker exec w2-se2 find /opt/oracle/oradata …&lt;/code&gt;. Harmless in a lab, and exactly the kind of thing I'd promised not to send. Now that integration is off, a filter drops any span that isn't &lt;code&gt;gen_ai.*&lt;/code&gt;, error stack frames lose their file paths (a home directory names its user), and tests check all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Gemma? I read every line
&lt;/h2&gt;

&lt;p&gt;The checks catch an epitaph that names the wrong ghost or invents a number. They can't catch a sentence that uses the right names and numbers and still says something untrue.&lt;/p&gt;

&lt;p&gt;So I took one real hunt (7 ghosts) and asked two sizes of Gemma 4 for its report 20 times each. Then I read all 280 epitaphs and 40 summaries against the facts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Same hunt, 20 runs each&lt;/th&gt;
&lt;th&gt;gemma4:e4b&lt;/th&gt;
&lt;th&gt;gemma4:26b&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Epitaphs and summaries passing the automatic checks&lt;/td&gt;
&lt;td&gt;160 / 160&lt;/td&gt;
&lt;td&gt;160 / 160&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Epitaphs with a false claim&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24 / 140&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1 / 140&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Summaries (two lines each) with a false claim&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5 / 20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 20&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median seconds per report (one call)&lt;/td&gt;
&lt;td&gt;1.05&lt;/td&gt;
&lt;td&gt;1.32&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both models passed every check on every run. Measured only by the checks, they'd be a tie. Read by a DBA, e4b's mistakes are the kind that sound right:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Here lies GH_AUDIT_2019, a table dropped on 2026-10-27. It still holds 7.0 MB &lt;strong&gt;of memory&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's disk space.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Here lies 4.4.159581, who finished a distributed transaction on 2026-10-27 22:37. It rests, &lt;strong&gt;having already been purged&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It hadn't been. Purging it &lt;em&gt;is&lt;/em&gt; the exorcism, and nobody had approved anything.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Here lies session 217, which began its work at 2026-10-27 22:37. It &lt;strong&gt;still waits for another session&lt;/strong&gt; to finish its task.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Backwards. It's the one being waited on.&lt;/p&gt;

&lt;p&gt;The 26B model made one mistake in 140 (the same "of memory"). It costs about a quarter of a second per report once loaded, so it's the default. Every line from all 40 runs is in the repo (&lt;a href="https://github.com/pyaroslav/ghost-hunt/blob/main/results/eval.json" rel="noopener noreferrer"&gt;&lt;code&gt;results/eval.json&lt;/code&gt;&lt;/a&gt;), with my count and criteria in &lt;a href="https://github.com/pyaroslav/ghost-hunt/blob/main/results/claims.md" rel="noopener noreferrer"&gt;&lt;code&gt;results/claims.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What I take from it: a validator tells you the model stayed inside the facts. It doesn't tell you the model understood them. That's also why, in Ghost Hunt, a wrong epitaph can make a report wrong in tone, but never makes the hunter touch the wrong thing. The ghosts, their order, the decisions and the SQL never come from the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Everything Ghost Hunt reads is what a DBA can't paste into a hosted chatbot: session lists, transaction ids, user names, file paths. Here, Gemma runs on the same machine as the databases. Temporal is one binary with a SQLite file. The only thing that can leave is the Sentry metadata above, and that's optional. A hunt has no API bill.&lt;/p&gt;

&lt;p&gt;Open weights mattered in a second way, too. The model choice above came from running the same input 40 times and reading every answer. With an open model on my own GPU, that's a few minutes and no bill. I can pin the exact model and rerun the comparison whenever a new Gemma comes out. Anyone with Ollama can repeat it from &lt;code&gt;results/ghosts.json&lt;/code&gt;, no Oracle needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do (yet)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It hunts two lab databases. The queries are standard dictionary views, and I'd expect them to work on any 12.2+ CDB, but I've only run them on these two.&lt;/li&gt;
&lt;li&gt;The in-doubt ghost needed two lab settings to stay put:

&lt;ul&gt;
&lt;li&gt;distributed recovery is disabled on both databases, or Oracle's recovery process (RECO) quietly resolves it within seconds;&lt;/li&gt;
&lt;li&gt;the database link goes from 12.2 to 26ai, because the other direction fails with &lt;code&gt;ORA-28267&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The file check needs to list the data directory, through &lt;code&gt;docker exec&lt;/code&gt; here.&lt;/li&gt;
&lt;li&gt;The false-claim count is one fixture, read by one person: me.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The row
&lt;/h2&gt;

&lt;p&gt;It reads 60 now. The 40 that left arrived on the other side, the transaction that carried it has an epitaph in a report on my disk, and the control file is still waiting for me to bury it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 26B, local on Ollama, writes the summary and every epitaph inside line-by-line checks, and was chosen over e4b by reading 280 epitaphs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Temporal.&lt;/strong&gt; The exorcism waits on a signal for up to twelve hours, survives its worker being killed, and touches only approved ghosts, re-checked, in a safe order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Sentry Agent Tracing.&lt;/strong&gt; One metadata-only &lt;code&gt;gen_ai&lt;/code&gt; trace per hunt, across a worker restart, including the leak I found in my own trace and the test that keeps it out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy Halloween, and to everyone keeping an ofrenda this weekend: may your databases remember only what they should.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>oracle</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My database died on Tuesday. I found out on Sunday. So now a local model reads my Oracle logs while I sleep</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:09:57 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/my-database-died-on-tuesday-i-found-out-on-sunday-so-now-a-local-model-reads-my-oracle-logs-while-4oma</link>
      <guid>https://dev.to/uptimearchitect/my-database-died-on-tuesday-i-found-out-on-sunday-so-now-a-local-model-reads-my-oracle-logs-while-4oma</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On Tuesday afternoon at 18:39 UTC, a background process in one of my lab databases died:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-20T18:39:10 Instance Critical Process (pid: 7, ospid: 3437, MMAN) died unexpectedly
                    PMON (ospid: 3425): terminating the instance due to error 822
2026-10-20T18:39:13 Instance terminated by PMON, pid = 3425
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;MMAN manages the database's memory. When it dies, Oracle takes the whole instance down with it, and that's what happened. Then nothing happened for five days. No alert, no email. The alert log doesn't write anything once the database is gone. I found out on Sunday evening, by accident, while looking for something else.&lt;/p&gt;

&lt;p&gt;Every DBA knows the cure, and nobody actually does it: read the alert log every morning. It's three hundred lines a night of log switches, resizes and scheduler windows. Somewhere in there might be the one line that matters. Some mornings there isn't one, and the morning there is, you've stopped reading.&lt;/p&gt;

&lt;p&gt;So I built a sloth to do it for me.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Night Shift&lt;/strong&gt; reads your Oracle alert and listener logs overnight and leaves you &lt;strong&gt;two lines&lt;/strong&gt; in the morning. Everything else stays in a list underneath, in case you want it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Night Shift&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; lab-26ai had an out of space issue; lab-se2 is down. lab-26ai also saw 37 checkpoint issues and 1 deadlock.&lt;/span&gt;
&lt;span class="gt"&gt;&amp;gt; Start lab-se2 immediately, then check the alert log entries from before it stopped.&lt;/span&gt;

&lt;span class="gu"&gt;## lab-26ai: up&lt;/span&gt;
2026-10-24 23:01 to 2026-10-25 23:01 UTC · 303 log lines, 263 routine
&lt;span class="p"&gt;
-&lt;/span&gt; 🔴 Out of space in a tablespace (ORA-01653), 2026-10-25 22:47 UTC. Add or extend a datafile, or find what grew.
  FREEPDB1(3):ORA-01653: unable to increase tablespace NS_TINY [FREEPDB1] by 1MB during insert or update on table LAB.NS_FILL
&lt;span class="p"&gt;-&lt;/span&gt; 🟡 Checkpoint not complete, 37× 2026-10-25 01:00 UTC → 2026-10-25 21:06 UTC. Redo logs are too small or too few for the load.
&lt;span class="p"&gt;-&lt;/span&gt; 🟡 Deadlocks (ORA-00060), 2026-10-25 22:47 UTC. Read the trace file: it names both sessions and the SQL.

&lt;span class="gu"&gt;## lab-se2: DOWN&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; 🔴 Database is not running now. no pmon process for ORCLCDB; last alert log entry 2026-10-20 18:39 UTC

Night Shift · Temporal run night-20261025-230121 · 1 s · summary by gemma4:e4b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's a real run from tonight, trimmed a little, against two lab databases on my desktop: Oracle 26ai Free and 12.2 Standard Edition 2. To give it something to find, I filled a 16 MB tablespace and caused a deadlock on the 26ai one. The SE2 one was still dead from Tuesday (more on that at the end).&lt;/p&gt;

&lt;p&gt;It's set up once and then left alone. A Temporal Schedule runs it at 6 AM. The brief lands in a Markdown file and a desktop notification. The model runs locally in Ollama. Night Shift never connects to the database: it reads the log files and checks the process list, and nothing leaves the machine.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/wHePK3i-vds" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/pyaroslav" rel="noopener noreferrer"&gt;
        pyaroslav
      &lt;/a&gt; / &lt;a href="https://github.com/pyaroslav/night-shift" rel="noopener noreferrer"&gt;
        night-shift
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Reads your Oracle alert and listener logs overnight and leaves a two-line brief for the morning. Gemma 4 on Ollama + Temporal, all local.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Night Shift&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/pyaroslav/night-shift/actions/workflows/tests.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/pyaroslav/night-shift/actions/workflows/tests.yml/badge.svg" alt="tests"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e"&gt;&lt;img src="https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e" alt="license: MIT"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Night Shift reads your Oracle alert and listener logs overnight and leaves a two-line brief for the morning.&lt;/strong&gt; It runs on your own machine: Gemma 4 on Ollama writes the brief, and a Temporal workflow makes sure it arrives.&lt;/p&gt;
&lt;p&gt;I built it because my lab database died on a Tuesday afternoon and I found out on Sunday night.&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;# Night Shift
&amp;gt; lab-26ai has an out of space tablespace; lab-se2 is down; lab-offsite logs are unreadable.
&amp;gt; Start lab-se2 and review its logs from before it stopped.

## lab-26ai: up
2026-10-24 22:56 to 2026-10-25 22:56 UTC · 302 log lines, 262 routine

- 🔴 Out of space in a tablespace (ORA-01653), 2026-10-25 22:47 UTC. Add or extend a datafile, or find what grew.
- 🟡 Checkpoint not complete, 37× 2026-10-25 01:00 UTC → 2026-10-25 21:06 UTC. Redo logs are too small or too few for the load.
-&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/pyaroslav/night-shift" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The obvious version of this project is one line of code: paste last night's alert log into a model and ask for a summary. I built that version too, as the baseline, and it's the reason the real one looks the way it does.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules sort the night, the model writes two lines
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/pyaroslav/night-shift/blob/main/nightshift/logs.py" rel="noopener noreferrer"&gt;&lt;code&gt;logs.py&lt;/code&gt;&lt;/a&gt; splits the alert log into timestamped entries. It handles 12c+ ISO timestamps and the old 11g &lt;code&gt;Tue Oct 20 18:39:10 2026&lt;/code&gt; style, plus the listener log. Each entry goes through about twenty plain patterns, written so a DBA can read the file and agree with every line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Critical:&lt;/strong&gt; instance terminations, dead background processes, ORA-600/7445, block corruption, a stuck archiver, a full tablespace, missing online redo logs, ORA-4031, running out of processes, the listener losing the service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look today:&lt;/strong&gt; deadlocks, checkpoint not complete, ORA-1555, failed scheduler jobs, connection timeouts, failed logins, swapping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routine:&lt;/strong&gt; log switches, resizes, scheduler windows, service updates. These are counted, never reported.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repeats are folded together. A job that fails every ten minutes is one finding with a count. One crash that writes four alert log entries ("terminating", two state dumps, "terminated") counts as one incident, not four. An ORA- code that matches no rule still shows up as "Other Oracle error", so a problem I didn't think of can't quietly disappear. And if you've decided to live with something, like the Statistics Advisor job that fails every ten minutes on a fresh 12.2 CDB, there's a &lt;code&gt;mute&lt;/code&gt; list.&lt;/p&gt;

&lt;p&gt;A deadlock line in the alert log only says "see the trace file", and nobody opens trace files at 7 AM. So for ORA-00060, Night Shift opens the trace itself. It reads the deadlock graph to tell the kind of deadlock apart: TX locks held and wanted in exclusive mode mean two sessions updated the same rows in opposite order; a TM lock usually means a foreign key without an index; share-mode waits point at duplicate-key inserts or bitmap indexes. Then it pulls each session's user and SQL. The client machine and program name stay out of the brief:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; 🟡 Deadlocks (ORA-00060), 2026-10-25 22:47 UTC. Row lock deadlock: two sessions updated the same rows
  in opposite order. Make the application touch rows in one order (e.g. by key), or commit sooner.
&lt;span class="p"&gt;  -&lt;/span&gt; session 230 (LAB): update ns_dl set v = 2 where id = 1
&lt;span class="p"&gt;  -&lt;/span&gt; session 42 (LAB): update ns_dl set v = 2 where id = 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two checks look at what &lt;em&gt;isn't&lt;/em&gt; in the log. Is pmon running? And did the alert log write anything at all? A database that crashed on Tuesday writes nothing on Wednesday, so a silent night is a critical finding, not a quiet one. My first run of the pmon check told me lab-26ai was down. It wasn't: since 23ai, the process is called &lt;code&gt;db_pmon_FREE&lt;/code&gt;, not &lt;code&gt;ora_pmon_FREE&lt;/code&gt;. Both names are checked now.&lt;/p&gt;

&lt;p&gt;Then &lt;strong&gt;Gemma 4 (e4b)&lt;/strong&gt; gets the night as a small JSON digest (about 500 characters per database), not the log, and writes exactly two lines. Its answer is checked before anyone sees it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exactly two lines;&lt;/li&gt;
&lt;li&gt;every number in them has to appear in the digest;&lt;/li&gt;
&lt;li&gt;every database with a critical finding has to be named.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It gets three tries, and then a plain template writes the lines instead. The list under the two lines never comes from the model, so even a bad summary can't hide an outage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Temporal makes sure it arrives
&lt;/h3&gt;

&lt;p&gt;A morning brief that silently doesn't arrive is worse than no tool at all, because by then you've stopped checking yourself. That's why it's a &lt;a href="https://temporal.io" rel="noopener noreferrer"&gt;Temporal&lt;/a&gt; workflow and not a cron job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each database is read in its own activity&lt;/strong&gt;, with retries at 15 s, 30 s and 60 s. A host that stays unreachable doesn't crash the run. It becomes a finding: "Couldn't read the logs. The night is unknown."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If Ollama is down all night&lt;/strong&gt;, the summary activity fails three times and the template writes the brief instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the worker dies halfway&lt;/strong&gt;, the run picks up where it stopped as soon as a worker comes back. Steps that already finished aren't repeated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Temporal Schedule&lt;/strong&gt; starts it at 06:00 America/Chicago, with the overlap policy set to skip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of it runs locally. &lt;code&gt;night-shift server&lt;/code&gt; starts Temporal's single-binary dev server with a SQLite file, so the history survives a reboot, and two systemd user units in the repo bring the server and worker back up afterwards. If the machine is asleep at 6 AM, the Schedule's catch-up window starts the missed run when it wakes up.&lt;/p&gt;

&lt;p&gt;Two of those promises (an unreachable host, and no model all night) have a test on Temporal's time-skipping test server. It runs in about a second in CI. For the rest, I tested by hand: I added a third "database" whose container doesn't exist. I started a run, killed the worker with &lt;code&gt;kill -9&lt;/code&gt; twenty seconds in, and started it again thirty seconds later:&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%2F1ppfesrhibvahcksisxa.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%2F1ppfesrhibvahcksisxa.png" alt="Temporal timeline: two databases read in seconds, the missing one retried four times across the worker restart, then the summary and delivery"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two real databases were read in under a second. The missing one was retried four times over 1 m 45 s, across the restart. Then Gemma wrote the summary and the brief was delivered, with the missing host as the third critical finding. The first time this happened wasn't planned. My first worker crashed on startup: Temporal's workflow sandbox doesn't allow &lt;code&gt;Path.resolve()&lt;/code&gt; at import time. The run I'd started just waited in the queue for ten minutes. When I fixed the worker, the run finished as if nothing had happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the brief say what matters?
&lt;/h3&gt;

&lt;p&gt;I replayed &lt;strong&gt;22 nights&lt;/strong&gt; from the two lab databases' logs (Oct 13 to tonight), each one the 24 hours up to 6 AM Central. I compared Night Shift with the one-line version: the same night's raw alert log, the same model, the same two-line instructions. The events that &lt;em&gt;must&lt;/em&gt; be in the two lines were found with plain grep on the raw log, independent of my rules: an instance died, a tablespace filled, a deadlock, a missing online redo log, or an alert log silent all night. That gave 8 events across 5 nights.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;gemma4:e4b, 22 nights&lt;/th&gt;
&lt;th&gt;Night Shift&lt;/th&gt;
&lt;th&gt;Raw log, same model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Must-report events named in the two lines&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7 / 8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6 / 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kept to two lines&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21 / 21&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;17 / 21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nights with numbers that aren't in the log&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7 (4 of them wrong counts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What the model reads (whole prompt)&lt;/td&gt;
&lt;td&gt;~1,200 characters&lt;/td&gt;
&lt;td&gt;up to 1.7 M characters before the cut&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What the numbers hide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The raw log is good on small nights.&lt;/strong&gt; On the night lab-se2 died, there were only four entries, and it wrote the better line: &lt;em&gt;"Instance terminated at 18:39:13 due to MMAN dying unexpectedly, triggering PMON error 822."&lt;/em&gt; Night Shift's line said the instance terminated, but not why. The why is in the list underneath.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On big nights it stops following instructions.&lt;/strong&gt; Four nights didn't fit the 32K-token context I gave it, so Ollama cut them down to about 16K tokens and most of the night was lost. On all four it ignored "two lines" and wrote a multi-page essay with headings and a priority table. On one of them, the night's only deadlock was in the part that got cut, so the model never saw it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can't count.&lt;/strong&gt; On four different nights it told me how many log switches there had been: 54, 33, 87 and 45. The real numbers were 61, 69, 58 and 77. That's four for four wrong, and each one sounded completely sure of itself. The other three nights' numbers are rounded file sizes it converted from KB, values from SQL it wrote itself, and the next log sequence after the window. Night Shift's counts come from Python. The model only gets to repeat them, and the number check rejects anything it makes up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can't see silence.&lt;/strong&gt; The night after the crash, there was nothing to send it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Night Shift's miss:&lt;/strong&gt; on lab-se2's first night, the two lines named the shutdown, the missing redo log and 17 job failures, but not the 2 deadlocks. They're in the list, but not in what you'd read at 7 AM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;These are lab logs, and the must-report events are few.&lt;/li&gt;
&lt;li&gt;One of the two "instance died" nights is a &lt;code&gt;shutdown abort&lt;/code&gt; I ran during setup.&lt;/li&gt;
&lt;li&gt;The tablespace was filled on purpose.&lt;/li&gt;
&lt;li&gt;I changed the prompt once after the first replay. The two lines kept skipping deadlocks, and Gemma was writing "as per the finding's 'do' field". That first run scored 6/8 and is in the repo next to the second. I also loosened one scoring pattern from "silent" to "silen" after Gemma wrote "alert log silence", which gave Night Shift one of its 7.&lt;/li&gt;
&lt;li&gt;A rerun with the final code, which adds the restart rule, scored 8/8 (&lt;code&gt;results/replay-v3.json&lt;/code&gt;). The two lines vary a little from run to run, so the table keeps the lower number.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;An alert log is a map of someone's infrastructure. It has host names, file paths, user names, SQL fragments, and the exact times their systems fell over. A DBA can't paste a client's into a hosted chatbot. I wouldn't want to give a SaaS agent a login to production just to read it, either.&lt;/p&gt;

&lt;p&gt;The open pieces are what make this possible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4 on Ollama&lt;/strong&gt; runs on the same desktop as the databases, at no cost per night, with no internet needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporal&lt;/strong&gt; is open source, so the durable part runs locally as one binary and a SQLite file. Nothing is hosted anywhere, and you still get retries, resume and schedules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can see why the model said what it said.&lt;/strong&gt; The exact digest it read is right there. When it's wrong, I fix a rule or the prompt instead of hoping the next model version is better. A closed API wouldn't have let me run the raw-log baseline on 22 nights for free either, and that baseline is what told me where the model needs help.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;Reading two alert logs properly takes a good ten minutes on a busy night, and nothing at all on the nights you skip it, which is how I lost five days. Now it's two lines with my coffee, and the full list only when a line is red.&lt;/p&gt;

&lt;p&gt;Its first real run told me what I'd missed all week: &lt;strong&gt;lab-se2 is DOWN, last alert log entry Tuesday 18:39 UTC.&lt;/strong&gt; So I started it. The next brief had moved on by itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## lab-se2: up
2026-10-25 00:03 to 2026-10-26 00:03 UTC · 90 log lines, 85 routine

- 🟡 Instance started, 2026-10-26 00:02 UTC. Check the restart was planned; the entries just before it say why it stopped.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also points at the listener's "address already in use" lines from the restart, which are worth a glance and nothing more. The Temporal Schedule fires for the first time at 6:00 tomorrow morning, and this is what will be waiting:&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%2Flf5s5g8czjqu0zq5ewjy.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%2Flf5s5g8czjqu0zq5ewjy.png" alt="Desktop notification from Night Shift with the first line of the brief"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Tonight, the sloth is on shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Temporal:&lt;/strong&gt; the brief is a Temporal workflow with one activity per database, retry policies, and a fallback when the model is down. A host that can't be reached turns into a finding instead of a crash. A Temporal Schedule runs it at 6 AM. I tested it by killing the worker mid-run and pointing it at a host that doesn't exist (timeline above). Everything runs locally on Temporal's open-source dev server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma 4 (e4b) runs locally in Ollama and writes the two lines from a compact digest, checked for invented numbers and missing databases. The replay compares it with the same model reading the raw log, night by night.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>oracle</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Checkpoint Not Complete: Every Commit Waits, and the SQL Is Fine</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sun, 25 Oct 2026 20:49:26 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/checkpoint-not-complete-every-commit-waits-and-the-sql-is-fine-3g41</link>
      <guid>https://dev.to/uptimearchitect/checkpoint-not-complete-every-commit-waits-and-the-sql-is-fine-3g41</guid>
      <description>&lt;p&gt;The nightly batch used to finish in twenty minutes. Now it takes an hour, and during that hour the application stutters: every few seconds, every session that writes anything (an order, a login timestamp, a session audit row) freezes for a second or two, then carries on. The SQL hasn't changed. The plans haven't changed. CPU is fine, the storage team says the disks are fine, and the alert log has the same two lines over and over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Thread 1 cannot allocate new log, sequence 23
Checkpoint not complete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is broken, and no query is slow. The database is waiting for itself: the &lt;strong&gt;online redo logs are too small for the rate at which the workload changes data&lt;/strong&gt;, so the log writer keeps running out of logs it is allowed to reuse. This post covers what that message means, why it freezes every writer at once, how to spot it before users do, and how to fix it online. It ends with a lab where the same update takes &lt;strong&gt;14.1 seconds&lt;/strong&gt; on two 20 MB redo logs, 11.9 of them waiting, and &lt;strong&gt;1.9 seconds&lt;/strong&gt; after the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the redo log circle works
&lt;/h2&gt;

&lt;p&gt;Every change in an Oracle database is first written as &lt;strong&gt;redo&lt;/strong&gt; to the online redo logs, a small, fixed set of files (&lt;em&gt;groups&lt;/em&gt;) that the log writer (LGWR) fills one after another in a circle. When the current group is full, LGWR &lt;strong&gt;switches&lt;/strong&gt; to the next one, and when it reaches the last group it wraps around and reuses the first.&lt;/p&gt;

&lt;p&gt;Reusing a group is only safe once nothing still needs the redo in it for crash recovery. The redo protects changes to data blocks that may still exist only in memory. So before a group can be overwritten, the &lt;strong&gt;checkpoint&lt;/strong&gt; that covers it must finish: the database writer (DBWR) has to write every dirty block whose changes are recorded in that group to the datafiles. Until that happens, &lt;code&gt;V$LOG&lt;/code&gt; shows the group as &lt;code&gt;ACTIVE&lt;/code&gt;, meaning still needed for recovery, and LGWR cannot touch it.&lt;/p&gt;

&lt;p&gt;Normally the circle is big enough that DBWR finishes long before LGWR comes back around, and the next group is always &lt;code&gt;INACTIVE&lt;/code&gt; and ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it freezes everyone
&lt;/h2&gt;

&lt;p&gt;When the logs are small relative to the write rate, LGWR fills the whole circle faster than DBWR can complete the checkpoints. It comes back to a group that is still &lt;code&gt;ACTIVE&lt;/code&gt;, and it has nowhere to write. The alert log records it as &lt;em&gt;"cannot allocate new log … Checkpoint not complete"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;LGWR is a single point of serialisation. Until DBWR frees that group, &lt;strong&gt;no session in the database can generate redo&lt;/strong&gt;: no insert, no update, no delete, no commit. They all wait on the same event, &lt;code&gt;log file switch (checkpoint incomplete)&lt;/code&gt;, and then all resume at once. That's the stutter. It's not one slow query; it's the whole instance pausing every time the log writer laps the checkpoint.&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%3AeNpVUl1rwjAU_SuXPE1Q9l6GY7NzCLKHVaaw7iE2t20wzS1Juk7U_74krbi9JTk5H_ckJ1aQQJYAKxX1Rc2Ng02aa4DtZ84ytFaStuABXSEI7vjD3tzP76S2aNwUutaf4RQKahrpJjn7gtlsDmtPXr9u36E30qEFg4IiUWpH4GqEojMGtQNFFVSGutZTg-068pennC3GGxGFslMqGSRsFND4c8VWb0-Lzerj5TFnl6CxDBrnnB3RJiBdiI_FoSVvDp4tSGPOzpCFCXvpihq4FnBAbGNeqasxzFVIUwLWSaVgMArsnWcvbrqaXCihVegwxsRvNMdhfgM9DzFIRySMXEqFYAfzu7_x9FVkMmbYxUJS75Y--0JLqaWtfaVj0tBFVBXSuCPsFRUHCyUZD3D3r9o0KmVhmQ2vFJaF4tZ6lz0Xt20GFZFgU2ANmoZL4X_IiXmrJv4VgSXvlGOXyy_AZsDY" 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%3AeNpVUl1rwjAU_SuXPE1Q9l6GY7NzCLKHVaaw7iE2t20wzS1Juk7U_74krbi9JTk5H_ckJ1aQQJYAKxX1Rc2Ng02aa4DtZ84ytFaStuABXSEI7vjD3tzP76S2aNwUutaf4RQKahrpJjn7gtlsDmtPXr9u36E30qEFg4IiUWpH4GqEojMGtQNFFVSGutZTg-068pennC3GGxGFslMqGSRsFND4c8VWb0-Lzerj5TFnl6CxDBrnnB3RJiBdiI_FoSVvDp4tSGPOzpCFCXvpihq4FnBAbGNeqasxzFVIUwLWSaVgMArsnWcvbrqaXCihVegwxsRvNMdhfgM9DzFIRySMXEqFYAfzu7_x9FVkMmbYxUJS75Y--0JLqaWtfaVj0tBFVBXSuCPsFRUHCyUZD3D3r9o0KmVhmQ2vFJaF4tZ6lz0Xt20GFZFgU2ANmoZL4X_IiXmrJv4VgSXvlGOXyy_AZsDY" alt="LGWR writes redo into a circle of online log groups. Before it can reuse a group, DBWR mus" width="450" height="1030"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;LGWR writes redo into a circle of online log groups. Before it can reuse a group, DBWR must finish the checkpoint covering it. With two 20 MB groups and a heavy write burst, LGWR comes back around to a group that is still ACTIVE, and every session that needs to write redo waits until DBWR frees it. With groups large enough to absorb the burst, the next group is always INACTIVE and nobody waits.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is a close cousin of &lt;a href="https://uptimearchitect.com/blog/oracle-ora-00257-archiver-stuck/" rel="noopener noreferrer"&gt;ORA-00257, archiver stuck&lt;/a&gt;. There, a log group can't be reused because the &lt;strong&gt;archiver&lt;/strong&gt; hasn't copied it yet, and the wait is &lt;code&gt;log file switch (archiving needed)&lt;/code&gt;. Here it's because the &lt;strong&gt;checkpoint&lt;/strong&gt; hasn't finished. Same circle, same freeze, different fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it
&lt;/h2&gt;

&lt;p&gt;The lab uses the community Oracle Database Free container image, which trims the redo logs to &lt;strong&gt;two groups of 20 MB each&lt;/strong&gt; to keep the image small: exactly the layout that causes this. It loads a table of 100,000 rows of about 1 KB, then runs one statement that rewrites every row and commits, a little over 200 MB of redo. It measures the elapsed time, the waits from &lt;code&gt;V$SYSTEM_EVENT&lt;/code&gt;, the switches from &lt;code&gt;V$LOG_HISTORY&lt;/code&gt;, and the &lt;code&gt;Checkpoint not complete&lt;/code&gt; lines in the alert log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;redo logs              elapsed   switches   waits (time waited)   alert lines
2 x 20 MB (image)       14.1 s         12        12 (11.9 s)              12
3 x 512 MB               1.9 s          0         0                        0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the default logs, &lt;strong&gt;84% of the update's elapsed time was waiting&lt;/strong&gt; for a redo log to become reusable: 11.9 of 14.1 seconds. The statement itself was never the problem. After the logs were resized, the identical update ran in 1.9 seconds with no switches and no waits.&lt;/p&gt;

&lt;p&gt;Elapsed times depend on the hardware and vary from run to run; on my machine the small-log update took between 8 and 14 seconds. The waits and the alert-log lines appear every time, and disappear every time after the resize.&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/checkpoint-incomplete" rel="noopener noreferrer"&gt;checkpoint-incomplete lab&lt;/a&gt; runs the burst on the default 2 x 20 MB logs and &lt;strong&gt;asserts&lt;/strong&gt; that &lt;code&gt;log file switch (checkpoint incomplete)&lt;/code&gt; waits and &lt;code&gt;Checkpoint not complete&lt;/code&gt; alert-log lines appear. It then resizes the logs online and asserts the same burst produces &lt;strong&gt;zero&lt;/strong&gt; of both. If the stall doesn't reproduce, or the fix doesn't remove it, the run fails. Proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How to spot it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The alert log&lt;/strong&gt; is the giveaway. Search it for &lt;code&gt;Checkpoint not complete&lt;/code&gt;; one occurrence during a once-a-year data load is noise, but dozens a day mean the logs are undersized for your real workload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The wait event.&lt;/strong&gt; &lt;code&gt;log file switch (checkpoint incomplete)&lt;/code&gt; shows up in &lt;code&gt;V$SYSTEM_EVENT&lt;/code&gt;, in AWR's Top Events and in ASH. In an &lt;a href="https://uptimearchitect.com/blog/how-to-read-an-awr-report/" rel="noopener noreferrer"&gt;AWR report&lt;/a&gt; it's often near the top during batch windows, and it's easy to misread as an I/O problem, because the time is spent waiting on DBWR's writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How often the logs switch.&lt;/strong&gt; &lt;code&gt;V$LOG_HISTORY&lt;/code&gt; has one row per switch:&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;TO_CHAR&lt;/span&gt;&lt;span class="p"&gt;(&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;first_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'HH24'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'YYYY-MM-DD HH24'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&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;AS&lt;/span&gt; &lt;span class="n"&gt;switches&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;log_history&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;first_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SYSDATE&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt;  &lt;span class="k"&gt;BY&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;first_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'HH24'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&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;p&gt;A common rule of thumb is a few switches per hour at peak, roughly one every 15 to 20 minutes. The lab's update switched 12 times in 14 seconds. If your busiest hours show switches &lt;em&gt;per minute&lt;/em&gt;, the logs are far too small for that workload, whether or not you've seen the alert yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Oracle's own sizing advice.&lt;/strong&gt; &lt;code&gt;V$INSTANCE_RECOVERY.OPTIMAL_LOGFILE_SIZE&lt;/code&gt; suggests a redo log size in megabytes, but only when &lt;code&gt;FAST_START_MTTR_TARGET&lt;/code&gt; is set. With it at 0, as on the lab database, the column is empty. In the lab, with a 60-second target, it suggested 827 MB. Treat it as a starting point, not a law: it's derived from the recovery target, not from your peak write rate.&lt;/p&gt;

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

&lt;p&gt;You can't resize a redo log file in place. Instead, you &lt;strong&gt;add new groups at the size you want, move onto them, and drop the old ones&lt;/strong&gt;, all online:&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. Add bigger groups (use your own paths, and two members each on separate disks in production)&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;ADD&lt;/span&gt; &lt;span class="n"&gt;LOGFILE&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/u01/oradata/PROD/redo03.log'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;SIZE&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="k"&gt;G&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;ADD&lt;/span&gt; &lt;span class="n"&gt;LOGFILE&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/u01/oradata/PROD/redo04.log'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;SIZE&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="k"&gt;G&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;ADD&lt;/span&gt; &lt;span class="n"&gt;LOGFILE&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/u01/oradata/PROD/redo05.log'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;SIZE&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="k"&gt;G&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Switch until one of the new groups is CURRENT, then checkpoint&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="n"&gt;SWITCH&lt;/span&gt; &lt;span class="n"&gt;LOGFILE&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;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;CHECKPOINT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Wait until the old groups show INACTIVE in V$LOG, then drop them&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;group&lt;/span&gt;&lt;span class="o"&gt;#&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;log&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;DROP&lt;/span&gt; &lt;span class="n"&gt;LOGFILE&lt;/span&gt; &lt;span class="k"&gt;GROUP&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="n"&gt;LOGFILE&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order matters.&lt;/strong&gt; A &lt;code&gt;CURRENT&lt;/code&gt; group can't be dropped, and neither can an &lt;code&gt;ACTIVE&lt;/code&gt; one, because it's still needed for recovery. Switch off it, checkpoint, and wait for &lt;code&gt;INACTIVE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dropping a group doesn't delete its file.&lt;/strong&gt; Remove the old files at the operating-system level afterwards (with Oracle Managed Files, the database does it for you).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size for the peak, not the average.&lt;/strong&gt; The goal is that, at your busiest, a log lasts long enough for DBWR to finish the checkpoint long before LGWR needs it again. In the lab, 512 MB groups hold the entire burst, so there is no switch at all. In production, aim for that rule of thumb of a few switches an hour at peak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More groups help too.&lt;/strong&gt; A third or fourth group gives DBWR more time before LGWR comes back around. Two groups is the bare minimum Oracle allows, and it leaves no slack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All groups the same size.&lt;/strong&gt; Mixed sizes make switch timing unpredictable and complicate standby redo logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other lever is DBWR itself: if it can't keep up because the storage is slow or asynchronous I/O is off, checkpoints take longer at any log size. (The lab container runs with &lt;code&gt;FILESYSTEMIO_OPTIONS=none&lt;/code&gt;, which doesn't help DBWR either.) But undersized logs are far more common, and resizing them is a five-minute, fully online change.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treating it as a storage problem.&lt;/strong&gt; The waits look like I/O, so the first instinct is faster disks. Faster writes help DBWR a little; logs sized for the workload remove the wait entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never changing the default.&lt;/strong&gt; Many databases still run on the redo logs they were created with, sized for the workload of the day they were built, or trimmed for a small footprint the way the lab's container image trims them to two 20 MB groups. Fine for a laptop, not for a batch window. Check yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting the standby.&lt;/strong&gt; With Data Guard, standby redo logs should be the same size as the online redo logs, with one more group per thread. Resize the online logs and leave the standby redo logs small, and the standby can't use them for real-time apply; it falls back to applying from archived logs. Resize both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confusing it with the archiver.&lt;/strong&gt; &lt;code&gt;log file switch (archiving needed)&lt;/code&gt; is &lt;a href="https://uptimearchitect.com/blog/oracle-ora-00257-archiver-stuck/" rel="noopener noreferrer"&gt;ORA-00257&lt;/a&gt;'s wait: the archive destination is full or slow. Bigger logs don't fix that one; freeing the destination does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fearing longer recovery, or ignoring it.&lt;/strong&gt; With &lt;code&gt;FAST_START_MTTR_TARGET&lt;/code&gt; at 0, how far the checkpoint lags is tied to the size of the smallest log, so bigger logs can mean more redo to replay after a crash. When you enlarge the logs, set &lt;code&gt;FAST_START_MTTR_TARGET&lt;/code&gt; (say, 60 to 300 seconds) and the database paces its checkpoint writes to keep recovery time bounded. Undersized logs cost you every busy day; properly sized ones cost a little disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dropping the wrong group.&lt;/strong&gt; Run &lt;code&gt;SELECT group#, status FROM v$log&lt;/code&gt; before every &lt;code&gt;DROP LOGFILE&lt;/code&gt;. A &lt;code&gt;CURRENT&lt;/code&gt; or &lt;code&gt;ACTIVE&lt;/code&gt; group can't be dropped, and in a hurry it's easy to type the wrong number.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What does 'Checkpoint not complete' mean in the Oracle alert log?
&lt;/h3&gt;

&lt;p&gt;It means the log writer (LGWR) filled the current online redo log and wanted to switch to the next group in the circle, but that group was still ACTIVE: the checkpoint covering it had not finished, so the database writer (DBWR) had not yet written all the dirty blocks whose changes are recorded in that log. The log cannot be overwritten until that happens, so LGWR waits, and while it waits no session can generate redo. The usual cause is online redo logs that are too small for the database's write rate, so LGWR laps the checkpoint during busy periods.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the 'log file switch (checkpoint incomplete)' wait event?
&lt;/h3&gt;

&lt;p&gt;It is the wait that sessions experience when they need to write redo but the log writer cannot switch to the next online redo log, because that log is still needed for crash recovery until a checkpoint completes. Every session that inserts, updates, deletes or commits waits on it at the same time, which is why it shows up as the whole database stalling rather than one slow query. In the companion lab, a 100,000-row update spent 11.9 of its 14.1 seconds on this wait with two 20 MB redo logs, and zero seconds after resizing them.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I resize Oracle online redo logs?
&lt;/h3&gt;

&lt;p&gt;Online redo log files cannot be resized in place. Add new log groups at the desired size with ALTER DATABASE ADD LOGFILE GROUP n ... SIZE, run ALTER SYSTEM SWITCH LOGFILE until one of the new groups is CURRENT, run ALTER SYSTEM CHECKPOINT, wait until V$LOG shows the old groups as INACTIVE, then drop them with ALTER DATABASE DROP LOGFILE GROUP n and delete their files at the operating-system level (Oracle Managed Files are removed automatically). The whole procedure is online. Keep all groups the same size, and in a Data Guard configuration resize the standby redo logs to match.&lt;/p&gt;

&lt;h3&gt;
  
  
  How big should Oracle redo logs be?
&lt;/h3&gt;

&lt;p&gt;Big enough that, at peak load, a log lasts long enough for the checkpoint covering the previous logs to finish well before the log writer needs them again. A common rule of thumb is a few log switches per hour at the busiest time, roughly one every 15 to 20 minutes. Check V$LOG_HISTORY for switches per hour; switches per minute mean the logs are far too small. V$INSTANCE_RECOVERY.OPTIMAL_LOGFILE_SIZE also suggests a size in megabytes, but only when FAST_START_MTTR_TARGET is set. Use at least three groups, all the same size.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is 'Checkpoint not complete' the same as ORA-00257 archiver error?
&lt;/h3&gt;

&lt;p&gt;No, although both freeze every session that writes. With 'Checkpoint not complete', the next online redo log cannot be reused because its checkpoint has not finished, and sessions wait on log file switch (checkpoint incomplete); the fix is usually larger or more redo log groups. With ORA-00257, the database is in ARCHIVELOG mode and the next log cannot be reused because the archiver has not been able to copy it, typically because the archive destination or Fast Recovery Area is full; sessions wait on log file switch (archiving needed), and the fix is to free or enlarge the archive destination.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do larger redo logs make crash recovery slower?
&lt;/h3&gt;

&lt;p&gt;Not in a way that should stop you from sizing them properly. Crash recovery time depends on how much redo must be applied since the last checkpoint position, and modern Oracle uses incremental checkpointing to write dirty blocks continuously rather than only at log switches. With FAST_START_MTTR_TARGET at 0, how far that checkpoint may lag is tied to the size of the smallest redo log, so larger logs can mean more redo to replay. Setting FAST_START_MTTR_TARGET makes the database pace its checkpoint writes to keep the estimated recovery time within the target, so set it when you enlarge the logs. Undersized logs, on the other hand, stall every writer on every busy day.&lt;/p&gt;

&lt;p&gt;Redo sizing is one of those settings that is decided once, at database creation, and then forgotten until the workload outgrows it. The symptoms point everywhere else: the &lt;a href="https://uptimearchitect.com/blog/oracle-wait-events-decoded/" rel="noopener noreferrer"&gt;wait events&lt;/a&gt; look like I/O, the batch looks slow, and the SQL looks guilty. Check the alert log for &lt;code&gt;Checkpoint not complete&lt;/code&gt;, count your log switches per hour, and if the logs are lapping the checkpoint, give them room: add bigger groups, switch, checkpoint, drop the old ones, and resize the standby redo logs to match. Then prove it the way that ends the argument — with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/checkpoint-incomplete" rel="noopener noreferrer"&gt;checkpoint-incomplete lab&lt;/a&gt;, where the same update drops from 14.1 seconds, 11.9 of them waiting, to 1.9.&lt;/p&gt;




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

</description>
      <category>oracle</category>
      <category>database</category>
      <category>devops</category>
      <category>sre</category>
    </item>
    <item>
      <title>Same SQL, Fast for One Value, Slow for Another</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:46:44 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/same-sql-fast-for-one-value-slow-for-another-22he</link>
      <guid>https://dev.to/uptimearchitect/same-sql-fast-for-one-value-slow-for-another-22he</guid>
      <description>&lt;p&gt;The order-history screen is fast for every customer except one. Or it's fast all morning and crawls all afternoon. Or it was fine for months and became slow the night after a restart, with nobody deploying anything. You pull the statement and run it yourself with the problem value, and it comes back instantly. The SQL is identical. The data hasn't changed. The plan in the execution history is the same plan it always uses.&lt;/p&gt;

&lt;p&gt;That last sentence is the clue. The statement uses a &lt;strong&gt;bind variable&lt;/strong&gt;, which is the right thing to do, and the cursor for it holds &lt;strong&gt;one plan&lt;/strong&gt;. That plan was chosen for whichever value happened to arrive first. On a column where values are wildly unevenly distributed, that's a plan built for one kind of caller and handed to every other. This post is how that happens, why Oracle's safety net for it, adaptive cursor sharing, doesn't always catch it, how to see it in the cursor cache, and how to fix it, with a lab that reproduces it on demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind peeking: the first caller picks the plan
&lt;/h2&gt;

&lt;p&gt;When a statement with a bind variable is hard-parsed, the optimizer &lt;strong&gt;peeks&lt;/strong&gt; at the actual bind value and costs the plan as if that value were a literal. That's usually good: a plan built for a real value beats a plan built for an average. The catch is what happens next. The finished cursor goes into the shared pool and &lt;strong&gt;every later execution reuses it&lt;/strong&gt;, whatever value it binds. The plan is decided once, by the first caller.&lt;/p&gt;

&lt;p&gt;On a column with evenly spread values, that doesn't matter: one value costs about the same as another. It matters on a &lt;strong&gt;skewed&lt;/strong&gt; column, where a few values are rare and one is everywhere: a status code where 99.9% of rows are &lt;code&gt;CLOSED&lt;/code&gt;, a country column dominated by one country, a flag that's almost always &lt;code&gt;N&lt;/code&gt;. If the column has a &lt;strong&gt;histogram&lt;/strong&gt;, the optimizer knows about the skew and builds very different plans for the rare value and the common one. Peeking means it builds &lt;strong&gt;one&lt;/strong&gt; of them, and everyone gets that one.&lt;/p&gt;

&lt;p&gt;The classic shape is a join. For a rare value, a few hundred driving rows, the best plan is &lt;strong&gt;nested loops&lt;/strong&gt;: for each order, look up its customer by primary key. For the common value, hundreds of thousands of rows, the best plan is a &lt;strong&gt;hash join&lt;/strong&gt;: read both tables once and match them in memory. Nested loops for the common value means one index probe per row, hundreds of thousands of times.&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%3AeNpNk1Fv0zAUhf_KlV9WpKwUBohFMLS16boJ1qkdQ4jswY1vGtMkjnyddVPb_75rN4W9RFFyfb5zzpU3IjMKRQwiL806K6R1cDdKa4DzP6mYSKugkZYQTA79fh9-TZJZAuSkawm-QkypeIDj4zO42KTiXpYtQoO4QvVlYd-emRpcgZBrSw7wCbPWaVN_S8XOEy78wW0qrLQYw9H0Nrk5gt77wQCsWdObVGxhyCZukBwqKI1pKIbGmgXC8Of8bvojmc07TOaxFoxV_OxZvSwc5MYCi7HOw2taZqrK1Mwbfp_OkxET352eRp_D4BZGITUV8NdoHrIoFSyMK8DJRYkEHtUJDkPuhA8MW0sMs9gSG_Xc5D6Z_YZH30c3nYTpMU__A9u2JqhfpwtpOjv7pBTBp8FJdPLxQ_BCndo4qF1y5-dKNk4_ImR7E8Qr1PUyKBGyYwlK5zlaZOeH5i-7LmoTA8kKYW3sqjQ-a5ut0PkmJmx17uSzzxy2uODfTSnrKGizeZC547r5rXN1kH1G8gpXYXlryApdqs7fPmJxaDiUdSikU5mEbNd8eKyfYqZjwIYFh0aDRK_UDJdlUHCFJu4Llc6kw8PGs1ISwTiaeOv_P4yiq-galsYoEYGo0FZSK74BG8Epq3AXFOayLZ3Y7V4A3Qz-PQ" 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%3AeNpNk1Fv0zAUhf_KlV9WpKwUBohFMLS16boJ1qkdQ4jswY1vGtMkjnyddVPb_75rN4W9RFFyfb5zzpU3IjMKRQwiL806K6R1cDdKa4DzP6mYSKugkZYQTA79fh9-TZJZAuSkawm-QkypeIDj4zO42KTiXpYtQoO4QvVlYd-emRpcgZBrSw7wCbPWaVN_S8XOEy78wW0qrLQYw9H0Nrk5gt77wQCsWdObVGxhyCZukBwqKI1pKIbGmgXC8Of8bvojmc07TOaxFoxV_OxZvSwc5MYCi7HOw2taZqrK1Mwbfp_OkxET352eRp_D4BZGITUV8NdoHrIoFSyMK8DJRYkEHtUJDkPuhA8MW0sMs9gSG_Xc5D6Z_YZH30c3nYTpMU__A9u2JqhfpwtpOjv7pBTBp8FJdPLxQ_BCndo4qF1y5-dKNk4_ImR7E8Qr1PUyKBGyYwlK5zlaZOeH5i-7LmoTA8kKYW3sqjQ-a5ut0PkmJmx17uSzzxy2uODfTSnrKGizeZC547r5rXN1kH1G8gpXYXlryApdqs7fPmJxaDiUdSikU5mEbNd8eKyfYqZjwIYFh0aDRK_UDJdlUHCFJu4Llc6kw8PGs1ISwTiaeOv_P4yiq-galsYoEYGo0FZSK74BG8Epq3AXFOayLZ3Y7V4A3Qz-PQ" alt="A bind-variable statement is optimized once, for the first value it sees, and the cursor i" width="708" height="1483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A bind-variable statement is optimized once, for the first value it sees, and the cursor is reused for every value after. On a skewed column with a histogram, a rare first value produces a nested-loops plan that is right for a few rows; the common value then inherits it and pays for one index probe per row. Adaptive cursor sharing can split the cursor into one plan per kind of value, but only once it has seen executions that differ enough; until then every caller gets the first caller's plan.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Adaptive cursor sharing: the safety net, with holes
&lt;/h2&gt;

&lt;p&gt;Oracle has known about this since 11g, and &lt;strong&gt;adaptive cursor sharing&lt;/strong&gt; (ACS) is its answer. A cursor that peeked a bind on a column with a histogram is marked &lt;strong&gt;bind-sensitive&lt;/strong&gt;. Oracle then watches its executions, sorting each one into a small number of buckets by how much work it did. If executions with different bind values land in clearly different buckets, the cursor becomes &lt;strong&gt;bind-aware&lt;/strong&gt;: from then on, each new value is checked against the existing child cursors, and a value that needs a different plan gets its own child cursor with its own plan.&lt;/p&gt;

&lt;p&gt;That works well when the difference is visible. The hole is in "clearly different". If the rare value's first execution and the common value's executions land in the &lt;strong&gt;same bucket&lt;/strong&gt;, ACS sees nothing to react to, and the cursor stays exactly as it was. And whether you're protected can depend on &lt;strong&gt;the order in which values arrive&lt;/strong&gt; after each restart, shared pool flush, or statistics refresh, which nobody controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it
&lt;/h2&gt;

&lt;p&gt;The lab builds &lt;code&gt;ORDERS&lt;/code&gt; with 200,000 rows and a skewed &lt;code&gt;STATUS&lt;/code&gt;, &lt;strong&gt;200 &lt;code&gt;OPEN&lt;/code&gt; and 199,800 &lt;code&gt;CLOSED&lt;/code&gt;&lt;/strong&gt;, with a frequency histogram on the column, and joins it to a 1,000,000-row &lt;code&gt;CUSTOMERS&lt;/code&gt; table. It flushes the shared pool, runs the statement once with &lt;code&gt;'OPEN'&lt;/code&gt;, then five times with &lt;code&gt;'CLOSED'&lt;/code&gt;, each in a fresh session, measuring session logical reads from &lt;code&gt;V$MYSTAT&lt;/code&gt; and the child cursor each execution ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt;&amp;gt; Fresh shared pool. The SAME statement, bound:  ... where o.status = :s
   OPEN    rows=200     logical reads=2624     child cursor=0  plan=NESTED LOOPS
   CLOSED  rows=199800  logical reads=603391   child cursor=0  plan=NESTED LOOPS
   CLOSED  rows=199800  logical reads=603354   child cursor=0  plan=NESTED LOOPS
   CLOSED  rows=199800  logical reads=603354   child cursor=0  plan=NESTED LOOPS
   CLOSED  rows=199800  logical reads=603354   child cursor=0  plan=NESTED LOOPS
   CLOSED  rows=199800  logical reads=603354   child cursor=0  plan=NESTED LOOPS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The optimizer peeked &lt;code&gt;'OPEN'&lt;/code&gt;, estimated 199 rows from the histogram, and chose nested loops, which is right for 200 rows. Every &lt;code&gt;'CLOSED'&lt;/code&gt; run then reused child cursor 0 and its nested loops: &lt;strong&gt;603,354 logical reads, five times in a row&lt;/strong&gt;. Adaptive cursor sharing never marked the cursor bind-aware. The first &lt;code&gt;'OPEN'&lt;/code&gt; execution, including its hard parse, and every &lt;code&gt;'CLOSED'&lt;/code&gt; execution fell into the same middle bucket, so the safety net never saw a difference. (The plan was even an adaptive plan, with a statistics collector that can switch join methods, but an adaptive plan settles on its first execution and the settled plan is what gets reused.)&lt;/p&gt;

&lt;p&gt;Give &lt;code&gt;'CLOSED'&lt;/code&gt; its own plan and the cost collapses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt;&amp;gt; The fix for a skewed, low-cardinality column: the predicate as a literal -&amp;gt; one cursor + plan per value
   CLOSED  rows=199800  logical reads=34716    child cursor=0  plan=HASH JOIN
   OPEN    rows=200     logical reads=806      child cursor=0  plan=NESTED LOOPS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;34,716 logical reads instead of 603,354&lt;/strong&gt;: the same rows, 17 times less work, because the plan was built for the value it runs. And &lt;code&gt;'OPEN'&lt;/code&gt; keeps its cheap nested loops at 806.&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/bind-peeking" rel="noopener noreferrer"&gt;bind-peeking lab&lt;/a&gt; builds the skewed tables on Oracle Database Free, peeks the rare value, and &lt;strong&gt;asserts&lt;/strong&gt; that every common-value run inherits nested loops on the same cursor at 10 times or more the work of a hash join, without adaptive cursor sharing splitting it. Then it gives each value its own plan and asserts the common value drops to 10% or less of that work. If the trap stops reproducing or the fix stops working, the run fails. Proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  See it in your own system
&lt;/h2&gt;

&lt;p&gt;Start from the statement, not the plan. A query whose cost swings by value shows up as &lt;strong&gt;high variance in buffer gets per execution&lt;/strong&gt; for one SQL_ID. Then look at its cursors:&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 row per child cursor: which ones are bind-sensitive / bind-aware, and what each costs&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;child_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan_hash_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_bind_sensitive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_bind_aware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;executions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer_gets&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;executions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;gets_per_exec&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;sql&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;sql_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;sql_id'&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;child_number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bind-sensitive &lt;code&gt;Y&lt;/code&gt;, bind-aware &lt;code&gt;N&lt;/code&gt;, one child, huge gets per execution:&lt;/strong&gt; the lab's situation. The cursor knows the bind matters, but it never split.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Several children with different plan hash values, bind-aware &lt;code&gt;Y&lt;/code&gt;:&lt;/strong&gt; ACS is doing its job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;V$SQL_CS_HISTOGRAM&lt;/code&gt;&lt;/strong&gt; shows how its executions were bucketed; all executions in one bucket means ACS has nothing to react to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To see which value the plan was built for, ask for the peeked binds:&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="s1"&gt;'&amp;amp;sql_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'TYPICAL +PEEKED_BINDS'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the lab that prints &lt;code&gt;:S ... 'OPEN'&lt;/code&gt; above a nested-loops plan, the whole diagnosis in one screen. In AWR, the same statement appearing with &lt;strong&gt;more than one plan hash value&lt;/strong&gt; over time, with very different elapsed times, is the historical fingerprint: the plan changes whenever the cursor is rebuilt with a different first value.&lt;/p&gt;

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

&lt;p&gt;Pick by how the column behaves. In order of preference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For a skewed, low-cardinality column, use a literal for that predicate.&lt;/strong&gt; A status code with five values makes five cursors, each with the right plan. That's no hard-parse storm: the storm comes from literal values with high cardinality, like IDs. Keep binds for everything else in the statement. This is the exception to "bind everything" that the &lt;a href="https://uptimearchitect.com/blog/oracle-bind-variables-hard-parse/" rel="noopener noreferrer"&gt;bind variables post&lt;/a&gt; ends on, and the lab's fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split the query in the application&lt;/strong&gt; when it already knows which kind of value it has: a separate statement for the "open orders" screen and the "history" screen. Two statements, two plans, no guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A SQL plan baseline with more than one accepted plan.&lt;/strong&gt; Baselines cooperate with adaptive cursor sharing: accept the nested-loops plan and the hash-join plan, and each value can use the one that fits. See &lt;a href="https://uptimearchitect.com/blog/oracle-sql-plan-management-baselines/" rel="noopener noreferrer"&gt;SQL plan management&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove the histogram&lt;/strong&gt;, if one plan is genuinely acceptable for every value. Without it the optimizer stops distinguishing values and picks one middle-of-the-road plan. It's a legitimate choice when no value is catastrophic, but a bad one when, as here, the wrong plan costs 17 times the work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What not to do: don't "fix" it by flushing the shared pool or purging the cursor when it goes bad. That just rolls the dice again on which value arrives first, and the problem returns on its own schedule.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blaming the data or the database for "random" slowness.&lt;/strong&gt; It isn't random: it's the plan built for whichever value arrived first, and it changes when the cursor is rebuilt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing by hand.&lt;/strong&gt; Paste the statement into a SQL tool with the slow value typed in and it's different SQL text, so it gets its &lt;em&gt;own&lt;/em&gt; hard parse and the right plan, and it's fast. The problem only shows on the shared, bind-variable cursor the application uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting adaptive cursor sharing to handle it.&lt;/strong&gt; It helps when executions differ enough to land in different buckets, and quietly does nothing when they don't, as the lab shows five times in a row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flushing the shared pool as the fix.&lt;/strong&gt; It resets the lottery; it doesn't change the odds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disabling bind peeking database-wide.&lt;/strong&gt; It's a hidden parameter, and switching it off trades one wrong plan for a plan built for no real value at all, for every statement in the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turning a whole application to literals to escape it.&lt;/strong&gt; That's the &lt;a href="https://uptimearchitect.com/blog/oracle-bind-variables-hard-parse/" rel="noopener noreferrer"&gt;hard-parse storm&lt;/a&gt; all over again. Use literals only for the skewed, low-cardinality predicate.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What is bind peeking in Oracle?
&lt;/h3&gt;

&lt;p&gt;Bind peeking is the optimizer's behavior of looking at the actual value of a bind variable when it hard-parses a statement, and optimizing the plan as if that value were a literal. It produces a plan suited to a real value rather than to an average. The resulting cursor is then stored in the shared pool and reused by every later execution of the same statement, regardless of the value those executions bind. So the plan is effectively chosen by whichever value happened to arrive first when the cursor was built. On columns with evenly distributed values this rarely matters; on skewed columns with histograms it can make the same SQL fast for one value and very slow for another.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is the same Oracle query fast for one value and slow for another?
&lt;/h3&gt;

&lt;p&gt;Usually because the statement uses a bind variable on a skewed column and the cursor's single plan was built for a different kind of value. If the first execution peeked a rare value, the optimizer may have chosen nested loops or an index-driven plan that is ideal for a few rows; every later execution reuses that plan, and a common value matching hundreds of thousands of rows then pays for one index probe per row. In the companion lab the same join costs 2,624 logical reads for a rare value and 603,354 for the common value on the inherited plan, versus 34,716 with its own hash-join plan. Running the query by hand with the slow value often looks fast, because a manual run with different text gets its own hard parse and the right plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is adaptive cursor sharing?
&lt;/h3&gt;

&lt;p&gt;Adaptive cursor sharing, introduced in Oracle 11g, lets a statement with bind variables have more than one plan. A cursor that peeked a bind on a column with a histogram is marked bind-sensitive, and Oracle monitors how much work its executions do, sorting them into buckets. If executions with different bind values land in clearly different buckets, the cursor is marked bind-aware, and new bind values are then checked against the existing child cursors; a value that needs a different plan gets a new child cursor with its own plan. You can see it in V$SQL (IS_BIND_SENSITIVE, IS_BIND_AWARE and multiple child cursors) and in V$SQL_CS_HISTOGRAM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why didn't adaptive cursor sharing fix my plan?
&lt;/h3&gt;

&lt;p&gt;Because adaptive cursor sharing only reacts when it sees executions that differ enough to fall into different workload buckets. If the rare value's first execution and the common value's executions land in the same bucket, the cursor stays bind-sensitive but never becomes bind-aware, and every execution keeps using the original plan. In the companion lab the first execution with the rare value, including its hard parse, and all five executions with the common value fell into the same bucket, so the cursor never split even though the common value did 17 times the necessary work each time. Whether a given workload is protected can also depend on the order in which values arrive after a restart, a shared pool flush, or a statistics refresh.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I find which bind value a plan was built for?
&lt;/h3&gt;

&lt;p&gt;Use DBMS_XPLAN.DISPLAY_CURSOR with the PEEKED_BINDS format option, for example SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('sql_id', child_number, 'TYPICAL +PEEKED_BINDS')). The output includes a Peeked Binds section showing the value the optimizer saw when it built that child cursor's plan. Combine it with V$SQL for the same SQL_ID: one row per child cursor, its plan hash value, whether it is bind-sensitive and bind-aware, and buffer gets per execution. A single child with huge gets per execution and a peeked value from the rare end of a skewed column is the bind-peeking signature.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use literals instead of bind variables to avoid bind peeking problems?
&lt;/h3&gt;

&lt;p&gt;Only for the specific predicate on a skewed, low-cardinality column, such as a status code or a flag with a handful of values. Each distinct literal gets its own cursor and its own plan, and with only a few distinct values that means only a few cursors, which is no burden on the shared pool. Keep bind variables for high-cardinality values like IDs, names, and dates: using literals there causes a hard-parse storm, with thousands of nearly identical cursors, shared pool pressure, and latch contention. The rule is bind by default, literal by exception, and the exception is low-cardinality skew.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does flushing the shared pool fix a bad bind-peeked plan?
&lt;/h3&gt;

&lt;p&gt;Only by accident, and only temporarily. Flushing the shared pool, or purging the one cursor with DBMS_SHARED_POOL.PURGE, forces a new hard parse, and the new plan will be built for whichever bind value arrives first afterwards. If that is the common value, things look fixed until the next time the cursor is rebuilt with the rare value first; if it is the rare value, nothing changes. It resets the lottery without changing the odds. Fix the cause instead: give the skewed predicate a literal, split the statement in the application, or use a SQL plan baseline with more than one accepted plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I disable bind peeking?
&lt;/h3&gt;

&lt;p&gt;No. Disabling bind peeking requires a hidden parameter, which should only be changed under Oracle Support's guidance, and it applies to every statement in the database. Without peeking, the optimizer builds plans using average selectivity rather than any real value, so instead of a plan that is right for some values you get a plan that is right for none of the skewed ones. Address the specific statements that suffer: literals for low-cardinality skewed predicates, separate statements per case, SQL plan baselines, or removing a histogram that does more harm than good.&lt;/p&gt;

&lt;p&gt;Bind peeking sits right at the meeting point of two other posts in this series. &lt;a href="https://uptimearchitect.com/blog/oracle-bind-variables-hard-parse/" rel="noopener noreferrer"&gt;Bind variables&lt;/a&gt; are why the cursor is shared in the first place, and that's the right default; &lt;a href="https://uptimearchitect.com/blog/oracle-indexes-help-vs-hurt/" rel="noopener noreferrer"&gt;indexes&lt;/a&gt; are why the same predicate deserves an index for one value and a full scan for another. The plan itself is something you can read in &lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;the execution plan&lt;/a&gt;, the histogram that sets the trap comes from &lt;a href="https://uptimearchitect.com/blog/oracle-optimizer-statistics-demystified/" rel="noopener noreferrer"&gt;optimizer statistics&lt;/a&gt;, and &lt;a href="https://uptimearchitect.com/blog/oracle-sql-plan-management-baselines/" rel="noopener noreferrer"&gt;SQL plan management&lt;/a&gt; is one way to pin more than one good plan. When one statement is fast for some values and slow for others, look at the peeked bind before anything else, and prove it the way that ends the argument — with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/bind-peeking" rel="noopener noreferrer"&gt;bind-peeking lab&lt;/a&gt;, where the same query costs 2,624 reads for one value and 603,354 for another until each value gets its own plan.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-bind-peeking-adaptive-cursor-sharing/" 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>A client sends you an AWR report under NDA. I built a second opinion that never leaves your laptop</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sun, 18 Oct 2026 17:16:29 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/a-client-sends-you-an-awr-report-under-nda-i-built-a-second-opinion-that-never-leaves-your-laptop-104d</link>
      <guid>https://dev.to/uptimearchitect/a-client-sends-you-an-awr-report-under-nda-i-built-a-second-opinion-that-never-leaves-your-laptop-104d</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every Oracle DBA who freelances knows this email. &lt;em&gt;"The system was slow yesterday between 2 and 4. AWR attached. Can you take a look?"&lt;/em&gt; Attached is a 200-page HTML report covering every wait event, every statement and every segment the database touched. The client is paying by the hour, and the report is covered by an NDA. It holds their SQL text, table names and host names.&lt;/p&gt;

&lt;p&gt;The first hour on a report like that is always the same triage. Is the problem parsing, I/O, locking, redo, or one bad statement? Load Profile, Top Events, SQL ordered by gets, back to the events, then the instance activity stats. It's skilled work, but it's routine. And the obvious shortcut is off the table: you can't paste a client's AWR report into a hosted chatbot.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Second Opinion&lt;/strong&gt;. It's a first-pass AWR and Statspack reviewer that runs entirely on your own machine, with an open model. Once the model is loaded, it takes under a second on my desktop GPU to rank the likely root causes, show the numbers behind each one, and draft a short note for the client. Every number in that note is checked against the report before you see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; on 18 held-out lab reports with faults I injected myself, Second Opinion named the cause in &lt;strong&gt;17 of 18&lt;/strong&gt;. Oracle's own ADDM got &lt;strong&gt;7/9&lt;/strong&gt; of the AWR ones, and Standard Edition can't run ADDM at all. Gemma 4 given the raw report got &lt;strong&gt;10/18&lt;/strong&gt;. Neither model ever quoted a number that wasn't in its input. I also fine-tuned Qwen3-8B on Tinker to rank the causes with no rules at all. Untuned it got 11/18; tuned, 16/18, with the top cause right on every run, and it runs locally in Ollama.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Second Opinion&lt;/strong&gt; is a command-line tool and a small Python library:&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="nv"&gt;$ &lt;/span&gt;second-opinion awrrpt_1_17_18.html          &lt;span class="c"&gt;# AWR, as awrrpt.sql writes it&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;second-opinion sp_10_11.lst                 &lt;span class="c"&gt;# Statspack, any release&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;second-opinion pasted.txt                   &lt;span class="c"&gt;# the sections a colleague pasted into a ticket&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It reads the report and runs the checks a DBA runs in their head. Gemma 4 then writes up what those checks found:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Statspack report · ORCLCDB 12.2.0.1.0 · 13-Oct-26 16:58:47 to 17:05:47 (7.0 min, DB time 27.71 min)

**High Hard Parse Rate Indicates Inefficient SQL Usage**
Likely causes, most important first: Hard parsing (literal SQL, no binds)

## Note for the client
The Statspack report indicates a significant performance issue primarily driven by hard parsing.
The evidence shows that 100.0% of parses are hard parses, with 8811.8 hard parses per second. This
high rate is contributing to the high wait time on library cache: mutex X, which accounts for 39.5%
of DB time. To address this, first, review the application code to ensure all SQL statements
utilize bind variables instead of literals. ...

## Findings (computed from the report, not by the model)
- Hard parsing (literal SQL, no binds) (score 1.0): Most parses are hard parses...
  hard_parses_per_sec=8811.8, parses_per_sec=8812.8, hard_parse_pct=100.0,
  shared_pool_event=library cache: mutex X, shared_pool_event_pct_db_time=39.5

_Numbers checked: 3; every number in the note was found in the report. Model: gemma4:e4b. 0.6 s, all local._
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's a real run on one of the held-out reports. It isn't perfect: the note's second suggestion, about indexing, has nothing to do with hard parsing. That's exactly why the output is a draft for a DBA, not something to send unread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who pays for this:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Freelance and contract DBAs.&lt;/strong&gt; They bill roughly $90–180 an hour, and on Fiverr an "AWR/Statspack performance review" package sells for around $220. Each report takes 30–60 minutes of triage before the real work starts. A tool that does the first pass, can't leak the report, and gives a draft client note gets that hour back on every engagement. Even one recovered billable hour a month would make $15–20 a month an easy sale. I'd price it as a one-time license per consultant, because "runs on your laptop" and "subscription" sit awkwardly together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shops on Standard Edition.&lt;/strong&gt; SE and SE2 can't license the Diagnostics Pack, so they have no AWR and no ADDM. Statspack is what they have, and often nobody reads it. Second Opinion treats Statspack as a first-class input, and on Standard Edition it is the only automated reviewer there is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed-service teams&lt;/strong&gt; that get a "slow yesterday" ticket at 2 a.m. and want a first read before they wake the senior DBA.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;A two-minute narrated walkthrough: a real report, the review, the Sentry traces, and the Tinker before/after.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/NHd2CHh6tTs" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/pyaroslav" rel="noopener noreferrer"&gt;
        pyaroslav
      &lt;/a&gt; / &lt;a href="https://github.com/pyaroslav/second-opinion" rel="noopener noreferrer"&gt;
        second-opinion
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A first-pass Oracle AWR/Statspack review on your own machine, with a local open model (Gemma 4) and a check that rejects invented numbers.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Second Opinion&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/pyaroslav/second-opinion/actions/workflows/tests.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/pyaroslav/second-opinion/actions/workflows/tests.yml/badge.svg" alt="tests"&gt;&lt;/a&gt;
&lt;a href="https://youtu.be/NHd2CHh6tTs" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/2ed8ec05d61d2e7b404e674bd78d40f440b2b31a267d0f8f06fca260181f5bd2/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f64656d6f2d322532306d696e253230766964656f2d726564" alt="demo video"&gt;&lt;/a&gt;
&lt;a href="https://huggingface.co/pyaroslav/second-opinion-qwen3-8b-lora" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/29fad9ee343ef644e4d5f04bfa0b8c09be88a6073a05efe3e87086d8b70cb67e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f2546302539462541342539372d4c6f5241253230616461707465722d79656c6c6f77" alt="model"&gt;&lt;/a&gt;
&lt;a href="https://github.com/pyaroslav/second-opinion/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/b8cadaa967891081f8f165695470689986c028821dd8a040132f6e661795dc0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c7565" alt="license"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/pyaroslav/second-opinion/docs/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fpyaroslav%2Fsecond-opinion%2FHEAD%2Fdocs%2Fdemo.gif" alt="Second Opinion reviewing a held-out AWR report in the terminal"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Second Opinion gives a first-pass review of an Oracle AWR or Statspack report. It runs on your own machine with a local open model, Gemma 4 via Ollama, so the report never leaves the box.&lt;/p&gt;
&lt;p&gt;This is a real run on one of the held-out lab reports (&lt;code&gt;reports/test/hard_parse-statspack-*&lt;/code&gt;):&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;$ second-opinion statspack.txt
# Second Opinion: statspack.txt
Statspack report · ORCLCDB 12.2.0.1.0 · 13-Oct-26 16:58:47 to 13-Oct-26 17:05:47 (7.0 min, DB time 27.71 min)

**High Hard Parse Rate Indicates Inefficient SQL Usage**

Likely causes, most important first: Hard parsing (literal SQL, no binds)

## Note for the client

The Statspack report indicates a significant performance issue related to SQL execution. The primary
finding is a high rate of hard parses, with 100.0% of parses being hard parses. This is evidenced by
the high hard_parses_per_sec of 8811.8 and the associated wait event library cache: mutex X
consuming 39.5% of&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/pyaroslav/second-opinion" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Don't hand the model the whole report
&lt;/h3&gt;

&lt;p&gt;My first attempt was the obvious one: give Gemma 4 the AWR report and ask what's wrong. An AWR report for an &lt;strong&gt;18-second&lt;/strong&gt; window is about 18,000 tokens of text, and a real one-hour report is far bigger. On my held-out set, Gemma reading the raw report found the cause in 10 of 18. It reads alarming-looking wait events as problems on healthy systems, and it misses every full table scan, because the evidence for a full scan is spread across three sections (SQL ordered by gets, instance activity, segments by table scans).&lt;/p&gt;

&lt;p&gt;So the model never reads the report. The work is split three ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parsers&lt;/strong&gt; (&lt;code&gt;awr.py&lt;/code&gt;, &lt;code&gt;statspack.py&lt;/code&gt;, &lt;code&gt;text.py&lt;/code&gt;) turn AWR HTML, AWR text, Statspack from any release, or a few pasted sections into one small dict: load profile, top events, time model, a few instance activity stats, top SQL per category, and segments. Statspack and AWR text are fixed-width, so column names are read from the header above each dashed rule. Pasted text often has its whitespace mangled, so rows are read token by token: a name, then numbers. On all 32 lab runs, the text parser gives exactly the same findings as the HTML parser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules&lt;/strong&gt; (&lt;code&gt;rules.py&lt;/code&gt;, about 150 lines) are the checks I'd run by hand: hard parsing, full scans (from logical reads, or from &lt;code&gt;db file scattered read&lt;/code&gt; when the scans come from disk), row locks, commit storms, NOCACHE sequences, one CPU-heavy statement, and Resource Manager CPU caps. Each fires with the numbers that made it fire. A materiality gate stops a large percentage of an idle database from becoming a finding: "74% of DB time is log file sync" means nothing at 0.1 active sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4&lt;/strong&gt; (e4b, local in Ollama, about 160 tokens per second on my GPU) only sees the findings and a little context. It ranks the causes, writes a headline and drafts the client note.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  A model that can't invent numbers
&lt;/h3&gt;

&lt;p&gt;A DBA can live with a model that writes clumsy prose. A model that invents a statistic, in a note going to a client, is a disaster. So every number in Gemma's note is checked against the numbers it was given, allowing for rounding and thousands separators. If any number isn't there, the note goes back once with the offending numbers listed. If a number still can't be found after the retry, it is printed in a warning, not quietly dropped.&lt;/p&gt;

&lt;p&gt;On the held-out set, neither Gemma reviewer quoted a number that wasn't in its input, and Second Opinion's retry never fired.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing it against faults I injected myself
&lt;/h3&gt;

&lt;p&gt;You can't evaluate a diagnosis tool on reports where nobody knows the answer, so I built a fault lab. There are two disposable Oracle databases in Docker on my workstation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;What it produces&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Oracle AI Database 26ai Free 23.26.3&lt;/td&gt;
&lt;td&gt;AWR + ADDM (Diagnostics Pack included in Free)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle Database 12.2 Standard Edition 2, &lt;code&gt;control_management_pack_access=NONE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Statspack only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;lab/faults.py&lt;/code&gt; runs a workload with a known fault for 5–7 minutes: literal SQL, a missing index, sessions fighting over the same rows, commit-per-row, a NOCACHE ORDER sequence, a cartesian join, or a healthy light load. It takes a snapshot before and after, then saves the AWR, ADDM or Statspack report a DBA would get.&lt;/p&gt;

&lt;p&gt;The rules were written while looking at a 14-run dev set. Then I wrote a &lt;strong&gt;held-out set&lt;/strong&gt;: the same causes, but different SQL, different tables and different session counts, plus two runs with two faults at once. I scored it once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Held-out, 18 runs.&lt;/strong&gt; A reviewer passes if the injected cause is in its top two. With two faults at once, both have to be in its top three.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reviewer&lt;/th&gt;
&lt;th&gt;AWR (9)&lt;/th&gt;
&lt;th&gt;Statspack (9)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Oracle ADDM&lt;/td&gt;
&lt;td&gt;7/9&lt;/td&gt;
&lt;td&gt;not available on SE2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4, raw report&lt;/td&gt;
&lt;td&gt;5/9&lt;/td&gt;
&lt;td&gt;5/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rules alone&lt;/td&gt;
&lt;td&gt;9/9&lt;/td&gt;
&lt;td&gt;8/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Second Opinion&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9/9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8/9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What the misses taught me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ADDM&lt;/strong&gt; found the right SQL statement in both full-scan runs, but its advice was "Run SQL Tuning Advisor", which needs the Tuning Pack. It never said that the query full-scans a 400,000-row table with no index on its &lt;code&gt;where&lt;/code&gt; column.

&lt;ul&gt;
&lt;li&gt;My first ADDM scoring used keyword matching and gave it 5/9. When I read the ADDM reports by hand, two "misses" were findings titled "Hard Parse" and "Sequence Usage", mislabelled because the text nearby mentioned CPU. I switched to mapping ADDM's finding titles, and 7/9 is the corrected figure.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our one miss&lt;/strong&gt; is the NOCACHE sequence on 12.2 Statspack. That report shows the commit pressure but no sequence wait in its top events. I left the rules as they are rather than tune them on the test set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lab caveats.&lt;/strong&gt; In the healthy AWR run, the light update workload hit two genuine ORA-00060 deadlocks. Second Opinion still called it healthy and listed row locks below the significance line. 26ai Free also caps its own CPU through Resource Manager, so busy runs show &lt;code&gt;resmgr:cpu quantum&lt;/code&gt;; that's real for that database, but an artefact of Free.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Real reports from the wild
&lt;/h3&gt;

&lt;p&gt;Lab faults are tidy. To see how it copes with what people actually paste, I collected ten AWR and Statspack reports posted publicly on the OraFAQ forums and Jonathan Lewis's blog. Each has an expert's diagnosis in the thread, and all ten are text, from Oracle 9i to 11g, and often only a few sections.&lt;/p&gt;

&lt;p&gt;Two things broke at first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I had no AWR-text parser, so I wrote one.&lt;/li&gt;
&lt;li&gt;My rules had never seen &lt;code&gt;db file scattered read&lt;/code&gt;, because in the lab every table fit in memory. On real systems that's the textbook sign of full scans from disk. I added that check, plus one that says "this wait dominates and nothing explains it" instead of calling such a system healthy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After that, the rules matched the expert's diagnosis on &lt;strong&gt;7 of the 10&lt;/strong&gt;. The other three were two excerpts that left out the SQL sections the diagnosis depended on, and one log-switch problem just under my threshold. I wrote those two checks &lt;em&gt;after&lt;/em&gt; reading these cases, so this is a set of worked examples, not a test score.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Thread&lt;/th&gt;
&lt;th&gt;Report&lt;/th&gt;
&lt;th&gt;Expert's call (paraphrased)&lt;/th&gt;
&lt;th&gt;Second Opinion&lt;/th&gt;
&lt;th&gt;Match&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.orafaq.com/forum/mv/msg/124437/345935/" rel="noopener noreferrer"&gt;Statspack report: suggestion&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Statspack&lt;/td&gt;
&lt;td&gt;Full scans of a big table (scattered reads)&lt;/td&gt;
&lt;td&gt;full scans&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.orafaq.com/forum/mv/msg/122717/337645/" rel="noopener noreferrer"&gt;statspack report&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Statspack&lt;/td&gt;
&lt;td&gt;Scattered reads on top: the SQL is full-scanning&lt;/td&gt;
&lt;td&gt;full scans&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://jonathanlewis.wordpress.com/2011/09/26/upgrade-argh/" rel="noopener noreferrer"&gt;Upgrade Argh&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;AWR text&lt;/td&gt;
&lt;td&gt;Small tables' scans turned into direct path reads after an upgrade&lt;/td&gt;
&lt;td&gt;full scans&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.orafaq.com/forum/mv/msg/185603/573992/" rel="noopener noreferrer"&gt;High System I/O wait class&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;AWR text&lt;/td&gt;
&lt;td&gt;log file sync dominates (slow redo writes vs. too many commits debated)&lt;/td&gt;
&lt;td&gt;commit storm, other&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://jonathanlewis.wordpress.com/2017/07/24/fast-now-fast-later/" rel="noopener noreferrer"&gt;Fast now, fast later&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Statspack&lt;/td&gt;
&lt;td&gt;Single-block reads grow as the data grows&lt;/td&gt;
&lt;td&gt;other: &lt;code&gt;db file sequential read&lt;/code&gt; dominates&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.orafaq.com/forum/mv/msg/187833/585763/" rel="noopener noreferrer"&gt;Network waits from AWR&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;AWR text&lt;/td&gt;
&lt;td&gt;Nothing to fix: network waits are a tiny share&lt;/td&gt;
&lt;td&gt;healthy&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.orafaq.com/forum/mv/msg/179660/547263/" rel="noopener noreferrer"&gt;Reading AWR&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;AWR text&lt;/td&gt;
&lt;td&gt;Mostly CPU, no instance-level problem&lt;/td&gt;
&lt;td&gt;healthy&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.orafaq.com/forum/mv/msg/184526/568790/" rel="noopener noreferrer"&gt;NOLOGGING in 11gR2&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;AWR text&lt;/td&gt;
&lt;td&gt;Log switches too frequent (checkpoint incomplete, ~19%)&lt;/td&gt;
&lt;td&gt;healthy&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://jonathanlewis.wordpress.com/2013/01/07/analysing-statspack-13/" rel="noopener noreferrer"&gt;Analysing Statspack 13&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Statspack excerpt&lt;/td&gt;
&lt;td&gt;CPU-bound on logical I/O from scans; needs the SQL sections&lt;/td&gt;
&lt;td&gt;healthy&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://jonathanlewis.wordpress.com/2007/04/19/analysing-statspack-5/" rel="noopener noreferrer"&gt;Analysing Statspack 5&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Statspack excerpt&lt;/td&gt;
&lt;td&gt;Repeated single-row deletes each doing a short full scan&lt;/td&gt;
&lt;td&gt;healthy&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The three misses are honest misses. Two are blog excerpts where the answer lives in SQL sections that weren't quoted, and a tool that only sees the Top 5 events can't find it there either. The third is a real configuration problem that sits below my 25% threshold.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fine-tuning a small model on Tinker
&lt;/h3&gt;

&lt;p&gt;The rules in Second Opinion are hand-written, and I wrote them knowing which faults I'd inject. So the fair question is whether a small open model could learn that triage itself from the parsed numbers, with no rules at all. Tinker let me try that for about the price of a coffee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data.&lt;/strong&gt; I left two generators running overnight against both lab databases: 524 short runs with random faults (one at a time, or two at once a quarter of the time), random session counts and 90–300 s windows. They use their own tables, SQL and sequences, so nothing overlaps the held-out set. A label is a fault that actually reached the report. I dropped 52 runs where the injected fault never showed. In one, for example, a literal-SQL flood had aged the CPU-heavy statement out of the shared pool before the snapshot, so no report could show it. That left 425 runs to train on and 61 to validate on (47 generated plus my 14 dev runs). The 18 held-out runs stayed untouched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Task.&lt;/strong&gt; The input is the parsed report, trimmed to what triage needs. The output is JSON ranking the likely causes. The base model and the tuned one get the same prompt, with Qwen3's thinking turned off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Training.&lt;/strong&gt; LoRA rank 32 on Qwen3-8B, 3 epochs, 81 steps, with a checkpoint saved after each epoch. Training used 3.66M tokens, about $1.61 at list price. With the early test run and the sampling for evaluation, the whole experiment cost roughly $2 of a $10 credit. I picked the epoch on the validation set:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Qwen3-8B, validation (61 runs)&lt;/th&gt;
&lt;th&gt;Pass&lt;/th&gt;
&lt;th&gt;Top cause right&lt;/th&gt;
&lt;th&gt;False causes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Base, untuned&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;136&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuned, epoch 1&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuned, epoch 2&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tuned, epoch 3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;57&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;61&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then I scored the base model and epoch 3 once on the 18 held-out runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Qwen3-8B, held-out (18 runs)&lt;/th&gt;
&lt;th&gt;AWR&lt;/th&gt;
&lt;th&gt;Statspack&lt;/th&gt;
&lt;th&gt;Top cause right&lt;/th&gt;
&lt;th&gt;False causes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Base, untuned&lt;/td&gt;
&lt;td&gt;5/9&lt;/td&gt;
&lt;td&gt;6/9&lt;/td&gt;
&lt;td&gt;11/18&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tuned&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8/9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8/9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18/18&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Pass" is the same rule as the main table: the cause in the top 2, or both causes in the top 3 when two were injected. A false cause is a label that wasn't injected. I don't count &lt;code&gt;cpu_cap&lt;/code&gt; as a false cause, because Free really does throttle itself, and the training labels include it when the report shows it.&lt;/p&gt;

&lt;p&gt;The base model's problem is easy to see: it names three causes every time, so it is never sure and often wrong. After tuning it names one or two, and the first one is right on every held-out run. Its two misses are both the full-scan + commit-storm pair, where it names the commit storm and drops the full scan.&lt;/p&gt;

&lt;p&gt;This doesn't replace the rules. The rules plus Gemma pipeline is still the tool, at 17/18 with a written note and every number checked. But a tuned 8B model with no rules reached 16/18 on the same runs. That tells me the triage isn't only in my head, and a model can learn it from lab data I can make as much of as I like.&lt;/p&gt;

&lt;p&gt;So it went into the tool as a second, independent read. &lt;code&gt;second-opinion report.html --triage-model second-opinion-qwen3&lt;/code&gt; adds the tuned model's own ranking under the review, checks the numbers in its evidence the same way, and says whether it agrees. On the held-out set it catches the review's one miss: on the 12.2 Statspack sequence run, the rules say "committing too often", and the tuned model says "sequence contention", which is right. It also disagrees on the healthy AWR run with real deadlocks. A disagreement isn't a verdict; it's a reason to read both before writing to the client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running it locally.&lt;/strong&gt; The tuned model only counts for this use case if it runs offline, so I exported the adapter to Ollama. That was harder than expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ollama 0.35 no longer takes a LoRA adapter in a Modelfile.&lt;/li&gt;
&lt;li&gt;Merging into the stock 4-bit model and saving it back at 4-bit lost the tuning.&lt;/li&gt;
&lt;li&gt;A base I converted myself from the Hugging Face weights gave repeating garbage once merged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What works is merging the adapter into the exact &lt;code&gt;qwen3:8b&lt;/code&gt; weights Ollama already has and stopping at 8-bit (&lt;code&gt;lab/tune/export.py&lt;/code&gt;). The local copy passes 16/18 too, and it agrees with Tinker's top cause on 17 of 18 runs, at about 0.5–0.7 s per report. The adapter is on Hugging Face: &lt;a href="https://huggingface.co/pyaroslav/second-opinion-qwen3-8b-lora" rel="noopener noreferrer"&gt;pyaroslav/second-opinion-qwen3-8b-lora&lt;/a&gt;, in PEFT and GGUF form. Where it differs, it misreads the healthy AWR run as row locks (the run with the real deadlocks), and it gets one full-scan + commit-storm pair that the Tinker copy missed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Watching the agent with Sentry
&lt;/h3&gt;

&lt;p&gt;Tracing an agent whose whole selling point is "nothing leaves your laptop" is a bit of a contradiction. So the first design decision was what Sentry may see. With &lt;code&gt;SENTRY_DSN&lt;/code&gt; set, each review becomes one &lt;code&gt;gen_ai.invoke_agent&lt;/code&gt; transaction with these child spans:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gen_ai.execute_tool&lt;/code&gt; for parsing;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gen_ai.execute_tool&lt;/code&gt; for the rules;&lt;/li&gt;
&lt;li&gt;one &lt;code&gt;gen_ai.chat&lt;/code&gt; per model call, including the retry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What leaves the machine: span names, durations, the model name, token counts, which causes the rules found, and how many numbers failed the check. There are no prompt or response attributes, &lt;code&gt;send_default_pii=False&lt;/code&gt;, and tracing is off unless you turn it on. Sentry's own agent view confirms it:&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%2F6ziloiuwnp8w7g8jgfb0.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%2F6ziloiuwnp8w7g8jgfb0.png" alt="A Second Opinion agent run in Sentry: one gemma4:e4b chat span, 1K tokens in and 199 out, and " width="800" height="115"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup.&lt;/strong&gt; I wrapped both reviewers in the benchmark as agents, "second-opinion" and "raw-baseline", and ran all 18 held-out reports through each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance (tokens, latency, cost).&lt;/strong&gt; The agents dashboard made the cost of the naive approach very concrete:&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%2Fdqzxiz4gq88l38dtd6sl.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%2Fdqzxiz4gq88l38dtd6sl.png" alt="Sentry AI Agents dashboard: raw-baseline runs use 16K–24K tokens each; second-opinion runs use 1.2K–1.4K" width="800" height="279"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Tokens per review&lt;/th&gt;
&lt;th&gt;Median latency (local GPU)&lt;/th&gt;
&lt;th&gt;Found the cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;raw-baseline (whole report to Gemma)&lt;/td&gt;
&lt;td&gt;16K–24K&lt;/td&gt;
&lt;td&gt;2.3 s&lt;/td&gt;
&lt;td&gt;10/18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;second-opinion (parse + rules + Gemma)&lt;/td&gt;
&lt;td&gt;1.2K–1.4K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.7 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17/18&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The dollar cost is $0 either way, because it runs locally. But about 15 times fewer tokens is what lets a 4B-parameter model run on a consultant's laptop at all, instead of needing a 32K context and a bigger GPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I found, and how I debugged it.&lt;/strong&gt; The first version of that table said 5.0 s for Second Opinion and 6.7 s for the raw baseline. Then I compared it with the traces from running the CLI on the same reports, and the CLI reviews took &lt;strong&gt;0.65–0.92 s&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2056gikgotgyafqrwgbf.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%2F2056gikgotgyafqrwgbf.png" alt="A CLI review waterfall: parse 133 ms, rules under 1 ms, one Gemma chat span 655 ms, total 789 ms" width="798" height="83"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same model, same prompt, but a 7× difference in latency. The waterfall put almost all the time in the &lt;code&gt;gen_ai.chat&lt;/code&gt; span, so the problem wasn't my code. In the benchmark, the two reviewers alternate, and they asked Ollama for different context sizes (16K for mine, 32K for the raw report). I checked Ollama's &lt;code&gt;load_duration&lt;/code&gt; directly. With the same context size, a warm call takes about 0.1 s. Alternating 16K and 32K forces a &lt;strong&gt;~4.2 s model reload on every single call&lt;/strong&gt;. The benchmark had been timing model reloads, not reviews.&lt;/p&gt;

&lt;p&gt;The fix is one line: a single context size for every call. After the fix, the same benchmark in Sentry alternates between 1.9–2.8 s for the raw baseline and 0.64–0.72 s for Second Opinion:&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%2Fsx05mzusd8e3n7nwr4pg.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%2Fsx05mzusd8e3n7nwr4pg.png" alt="Sentry after the fix: held-out traces alternate between ~2–2.8 s (raw-baseline, 16K–25K tokens) and ~0.7 s (second-opinion, 1K tokens)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Accuracy didn't change, but every latency figure in this post did, and without the traces I would have published the wrong ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;For this job, "open" is the product, not a nice-to-have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The report can't leave the machine.&lt;/strong&gt; An AWR report is a map of a client's system: SQL text, schema and table names, host names, sometimes bind values. A DBA under NDA can't paste it into a hosted model, however good that model is. An open-weight model running in local Ollama is the only way an LLM gets to touch this work at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Statspack shops are the ones without money.&lt;/strong&gt; The people with no Diagnostics Pack, and so no ADDM, are exactly the people who won't buy another subscription. A free open model on their own hardware is the only version of this they'll ever use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could change the model, not just the prompt.&lt;/strong&gt; With open weights I could fine-tune a small model for this exact task on Tinker, merge the adapter into the model Ollama already had, and run it offline. It agrees with the Tinker-hosted copy on 17 of 18 held-out runs. A closed API would have given me a prompt and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every step is inspectable.&lt;/strong&gt; The parser, the rules and the number check are about 900 lines of readable Python. A DBA can read why the tool said what it said, which is what they'd demand before putting their name on a client note.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My First User
&lt;/h2&gt;

&lt;p&gt;I didn't want to put a client's report anywhere near this, so the first "user" was a blind reviewer. A separate Claude instance, with no access to my code, labels or results, was told to act as a senior DBA. It was given six lab reports it had never seen, renamed &lt;code&gt;report-1&lt;/code&gt; to &lt;code&gt;report-6&lt;/code&gt;, and asked for a by-hand triage: ranked causes, the numbers that convinced it, and what it would check next. I also renamed one sequence whose name gave the answer away. Then I scored its read and Second Opinion's against the injected faults, with the same pass rule as everywhere else:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Report&lt;/th&gt;
&lt;th&gt;Injected&lt;/th&gt;
&lt;th&gt;Blind reviewer&lt;/th&gt;
&lt;th&gt;Second Opinion&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWR&lt;/td&gt;
&lt;td&gt;full scan&lt;/td&gt;
&lt;td&gt;full scan, CPU cap ✓&lt;/td&gt;
&lt;td&gt;full scan, CPU cap, CPU SQL ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Statspack&lt;/td&gt;
&lt;td&gt;NOCACHE sequence&lt;/td&gt;
&lt;td&gt;commits, sequence ✓&lt;/td&gt;
&lt;td&gt;commits, sequence ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Statspack&lt;/td&gt;
&lt;td&gt;healthy&lt;/td&gt;
&lt;td&gt;healthy ✓&lt;/td&gt;
&lt;td&gt;healthy ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWR&lt;/td&gt;
&lt;td&gt;commit storm&lt;/td&gt;
&lt;td&gt;commits, small redo logs ✓&lt;/td&gt;
&lt;td&gt;commits ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Statspack&lt;/td&gt;
&lt;td&gt;row locks + literal SQL&lt;/td&gt;
&lt;td&gt;hard parse, row locks ✓&lt;/td&gt;
&lt;td&gt;hard parse, row locks ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWR&lt;/td&gt;
&lt;td&gt;CPU-heavy SQL + literal SQL&lt;/td&gt;
&lt;td&gt;commits, CPU SQL ✗&lt;/td&gt;
&lt;td&gt;hard parse, commits, CPU cap ✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What I learned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5 of 6 each, and both missed the same report, from opposite sides.&lt;/strong&gt; In the last report, the reviewer found the CPU-heavy regex query but set the literal SQL aside, because hard parsing was only 3.5% of DB time (ADDM agreed). Second Opinion ranked the literal SQL first and never named the regex query, even though it takes a third of DB time. The CPU-SQL rule only fires when CPU is at least half of DB time. Here commit waits and the Resource Manager cap took so much of the time that CPU was only 36%, so the rule never fired. A single statement taking a third of DB time should count whatever else is going on. That gate is my next fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My label was wrong, and both of them caught it.&lt;/strong&gt; That run's literal-SQL workload commits after every row, so there really is a commit storm (log file sync at 42% of DB time). Both reviewers named it. My injected-fault label didn't. Lab labels are what I &lt;em&gt;meant&lt;/em&gt; to inject, not always everything the database actually did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reviewer added things the tool can't say yet:&lt;/strong&gt; undersized redo logs (32 log switches in 3 minutes), and that the CPU-cap waits are a symptom of the full scan, not a separate problem. Both are going on the list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the "user" is a model playing a DBA, not a paying consultant, and six reports is a small sample. A real DBA's blind pass is the next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Tinker:&lt;/strong&gt; a LoRA fine-tune of Qwen3-8B on 425 lab reports I generated with injected faults, picked on a 61-run validation set and scored once on 18 held-out runs. It went from 11/18 to 16/18, with the top cause right on 18/18 instead of 11/18 and false causes down from 38 to 0, for about $2. Then I exported it to run offline in Ollama, since a model that has to phone home is useless for reports under NDA. The adapter is published on &lt;a href="https://huggingface.co/pyaroslav/second-opinion-qwen3-8b-lora" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Sentry Agent Tracing:&lt;/strong&gt; every review is traced as an agent run (parse → rules → &lt;code&gt;gen_ai.chat&lt;/code&gt;), with tokens and latency per call and no report data leaving the machine. The traces showed that the raw-report approach costs about 15 times more tokens. They also caught a benchmark bug: Ollama reloading the model on every call, which had inflated my latencies 7×.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>oracle</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Cutover Went Perfectly. The First New Order Failed With ORA-00001.</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sun, 18 Oct 2026 13:32:12 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/cutover-went-perfectly-the-first-new-order-failed-with-ora-00001-2jdc</link>
      <guid>https://dev.to/uptimearchitect/cutover-went-perfectly-the-first-new-order-failed-with-ora-00001-2jdc</guid>
      <description>&lt;p&gt;The migration went by the book. The initial load ran days ahead. On cutover night the final data refresh finished early, row counts matched table for table, the reconciliation report was green, and the application was pointed at the new database. Ten minutes later the first customer placed an order and got an error page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ORA-00001: unique constraint (NEWSHOP.SYS_C008699) violated on table NEWSHOP.ORDERS columns (ID)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing was lost and nothing was corrupted. Every row was there. The problem was a number that no data check looks at: the &lt;strong&gt;sequence&lt;/strong&gt; that generates new order IDs was still where the initial load left it, days earlier, and it was handing out IDs the source had already used. This post is how that happens, why rehearsals tend to miss it, the check that catches it before go-live, and the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sequences are objects, not data
&lt;/h2&gt;

&lt;p&gt;An Oracle sequence is a schema object that hands out numbers: &lt;code&gt;ORDER_SEQ.NEXTVAL&lt;/code&gt; returns 9,001, then 9,002, and so on. The application uses it for new keys. Crucially, the sequence's current position is part of its &lt;strong&gt;definition&lt;/strong&gt;, not part of any table's data. When you export a sequence, Data Pump writes a &lt;code&gt;CREATE SEQUENCE … START WITH n&lt;/code&gt; statement, where &lt;code&gt;n&lt;/code&gt; is where the sequence stood at that moment.&lt;/p&gt;

&lt;p&gt;That one fact sets the trap. Many migrations work like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Initial load&lt;/strong&gt;, days or weeks before cutover: export and import the whole schema, tables &lt;em&gt;and&lt;/em&gt; sequences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The source keeps running.&lt;/strong&gt; Customers keep placing orders; &lt;code&gt;ORDER_SEQ&lt;/code&gt; keeps advancing on the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final refresh&lt;/strong&gt; on cutover night: copy just the &lt;strong&gt;table data&lt;/strong&gt; again, so the target catches up. With Data Pump that is &lt;code&gt;CONTENT=DATA_ONLY&lt;/code&gt; with &lt;code&gt;TABLE_EXISTS_ACTION=TRUNCATE&lt;/code&gt;; with other tools it's a final catch-up load or replication.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is deliberately data-only so it doesn't touch the target's objects, grants and code. Sequences are objects, so they are not touched either. The tables now contain every order up to 10,000; the sequence still thinks the next order is 9,001.&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%3AeNpdkm9PwjAQh7_Kpa80Qtx4YSLxTyAMJTEQ2BJjnDFlvUFjWbXtRIJ8d28tqOFN0za95577pVtWaIGsC6xUel0suXGQDfIKYPScs1ElneQKlOaiC47PFVo4A4sfNVYF2qu5Ob9x3CzQwWQ2SGavaTKFCr8cXMNlK4rinL1Au30DKdFSXZsC4Q3x3RLsTVYL0EagCRwpbKiBvO5EnQuIIzpFRGh0Uo8ZEmYoK1IyWBq0yy4MelkPJuOHp_8yRq8tKQRCC1b860SK09-bPXPomdMtqR0mIq7d0EZ42hxLbRAWuq3kJ97mbNeUTZuy75xVOmff0Cel7DgC66RSx0E0tX3fMvFjGOvoyTpk0AwdX3aI0WuTYRR7AfxEsyEnR2uhlZKCFOuK6GGSA_agtEHbhVmSZr1ZBmF9HGX3hwDO4sb4jrqPg1qA-Fal9wkuti4KRGH39EJxa6HfSmDOxd_FHQWjBWsBW6FZcSnoG22ZW-LKfyiBJa-VY7vdD8ZtvOM" 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%3AeNpdkm9PwjAQh7_Kpa80Qtx4YSLxTyAMJTEQ2BJjnDFlvUFjWbXtRIJ8d28tqOFN0za95577pVtWaIGsC6xUel0suXGQDfIKYPScs1ElneQKlOaiC47PFVo4A4sfNVYF2qu5Ob9x3CzQwWQ2SGavaTKFCr8cXMNlK4rinL1Au30DKdFSXZsC4Q3x3RLsTVYL0EagCRwpbKiBvO5EnQuIIzpFRGh0Uo8ZEmYoK1IyWBq0yy4MelkPJuOHp_8yRq8tKQRCC1b860SK09-bPXPomdMtqR0mIq7d0EZ42hxLbRAWuq3kJ97mbNeUTZuy75xVOmff0Cel7DgC66RSx0E0tX3fMvFjGOvoyTpk0AwdX3aI0WuTYRR7AfxEsyEnR2uhlZKCFOuK6GGSA_agtEHbhVmSZr1ZBmF9HGX3hwDO4sb4jrqPg1qA-Fal9wkuti4KRGH39EJxa6HfSmDOxd_FHQWjBWsBW6FZcSnoG22ZW-LKfyiBJa-VY7vdD8ZtvOM" alt="The initial load copies the table and the sequence next = 9,001. The source keeps taking o" width="526" height="1051"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The initial load copies the table and the sequence (next = 9,001). The source keeps taking orders up to id 10,000. The final refresh copies table data only, so the target has every row but its sequence never moved. The first insert after go-live asks the sequence for 9,001, which already exists, and fails with ORA-00001. Resetting the sequence past max(id) before go-live prevents it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why rehearsals miss it
&lt;/h2&gt;

&lt;p&gt;Two things make this bug unusually good at surviving testing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every data check passes.&lt;/strong&gt; Row counts, checksums and reconciliation reports compare data. The data is correct. Nothing in a typical validation compares a sequence's position to the keys in its table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cached sequences hide it.&lt;/strong&gt; With a cached sequence, Data Pump exports the sequence's &lt;code&gt;LAST_NUMBER&lt;/code&gt;, which is the top of its in-memory cache, not the last value actually used. In the lab's first run, with &lt;code&gt;CACHE 20&lt;/code&gt; on a release that grows sequence caches dynamically, the source had used 9,000 values but the target sequence started at &lt;strong&gt;22,221&lt;/strong&gt;. A rehearsal with a few hundred test orders never reaches that headroom. Production, with days of real orders between initial load and cutover, does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And when it does fail, it fails noisily but confusingly. A failed insert still consumes a &lt;code&gt;NEXTVAL&lt;/code&gt;, so an application that retries walks the sequence forward one collision at a time. In the lab that is 1,000 failed orders before the sequence reaches a free key. Users see intermittent errors that "fix themselves", which is the worst kind to diagnose under cutover pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it
&lt;/h2&gt;

&lt;p&gt;The lab builds a source schema &lt;code&gt;SHOP&lt;/code&gt; with 9,000 orders keyed by &lt;code&gt;ORDER_SEQ&lt;/code&gt;, then runs the migration exactly as above with &lt;code&gt;expdp&lt;/code&gt;/&lt;code&gt;impdp&lt;/code&gt;: a full initial load into &lt;code&gt;NEWSHOP&lt;/code&gt;, 1,000 more orders on the source, and a final &lt;code&gt;CONTENT=DATA_ONLY&lt;/code&gt; refresh.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;initial load    source  9,000 rows, max(id)  9,000 | target  9,000 rows, max(id)  9,000, ORDER_SEQ next 9,001
source drift    source 10,000 rows, max(id) 10,000 | target (unchanged)
final refresh   source 10,000 rows, max(id) 10,000 | target 10,000 rows, max(id) 10,000, ORDER_SEQ next 9,001
check           NEWSHOP.ORDER_SEQ  next=9001  max(ORDERS.ID)=10000  BEHIND by 1000
first insert    ORA-00001: unique constraint violated on table NEWSHOP.ORDERS columns (ID)
fix             ALTER SEQUENCE newshop.order_seq RESTART START WITH 10001
first insert    OK, new id = 10001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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/sequence-cutover" rel="noopener noreferrer"&gt;sequence-cutover lab&lt;/a&gt; runs this whole migration on Oracle Database Free with real &lt;code&gt;expdp&lt;/code&gt;/&lt;code&gt;impdp&lt;/code&gt;. It &lt;strong&gt;asserts&lt;/strong&gt; that the row counts match after the final refresh, that the pre-go-live check flags the sequence as behind, that the first insert fails with ORA-00001, and that after the fix the same insert succeeds with a key above everything migrated. If any of those stops being true, the run fails. Proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The check: run it before you open the doors
&lt;/h2&gt;

&lt;p&gt;For every sequence that feeds a key, compare the value it will hand out next with the highest key already in the table. Oracle doesn't record which sequence feeds which column (unless it's a column &lt;code&gt;DEFAULT&lt;/code&gt; or an identity column), so the mapping belongs in the cutover runbook. The check itself is short:&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;-- next value each sequence will hand out vs the highest key in the table it feeds&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence_owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_number&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;next_value&lt;/span&gt;&lt;span class="p"&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;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;newshop&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;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_number&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;newshop&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;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'BEHIND'&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;dba_sequences&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence_owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'NEWSHOP'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ORDER_SEQ'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lab's version loops over a list of &lt;code&gt;sequence =&amp;gt; table.column&lt;/code&gt; pairs, so one run covers every sequence in the application. Any &lt;code&gt;BEHIND&lt;/code&gt; row is a guaranteed ORA-00001 waiting for its first insert.&lt;/p&gt;

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

&lt;p&gt;Move each lagging sequence past the highest existing key:&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;SEQUENCE&lt;/span&gt; &lt;span class="n"&gt;newshop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_seq&lt;/span&gt; &lt;span class="k"&gt;RESTART&lt;/span&gt; &lt;span class="k"&gt;START&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="mi"&gt;10001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- max(id) + 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RESTART START WITH&lt;/code&gt; (18c and later) resets the sequence in place, so grants, synonyms and dependent code stay intact. On older releases, either temporarily change &lt;code&gt;INCREMENT BY&lt;/code&gt; to the gap, call &lt;code&gt;NEXTVAL&lt;/code&gt; once and set it back, or drop and recreate the sequence and re-grant it. For &lt;strong&gt;identity columns&lt;/strong&gt;, Oracle can do the arithmetic for you: &lt;code&gt;ALTER TABLE newshop.orders MODIFY id GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE)&lt;/code&gt; moves the identity's next value past the current maximum.&lt;/p&gt;

&lt;p&gt;The durable fix is in the runbook, not the database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make "resync sequences" an explicit cutover step&lt;/strong&gt;, after the final data load and before go-live, using &lt;code&gt;max(key) + 1&lt;/code&gt; (plus a margin if anything else might still write).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Or re-export sequences with the final data.&lt;/strong&gt; Include the sequence objects in the final refresh and replace the target's copies, so they carry the source's position at cutover time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the check as a go/no-go gate.&lt;/strong&gt; A cutover isn't done when the rows match. It's done when the sequence check reports no &lt;code&gt;BEHIND&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treating "row counts match" as "migration verified".&lt;/strong&gt; Data checks prove the data. They say nothing about the objects that generate the next row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rehearsing with too little traffic.&lt;/strong&gt; A cached sequence's export position includes headroom. If the rehearsal doesn't generate more keys between initial load and refresh than that headroom, the bug never shows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing it by dropping the primary key "temporarily".&lt;/strong&gt; That turns a loud error into silent duplicate IDs. Fix the sequence, never the constraint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resetting to exactly &lt;code&gt;max(id)&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;START WITH&lt;/code&gt; is the next value handed out, so it must be &lt;code&gt;max(id) + 1&lt;/code&gt;. &lt;code&gt;RESTART START WITH max(id)&lt;/code&gt; produces one more collision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting sequences that don't feed a primary key.&lt;/strong&gt; Invoice numbers, external reference IDs and other unique business keys come from sequences too, and collide the same way.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Why do I get ORA-00001 right after a database migration?
&lt;/h3&gt;

&lt;p&gt;Usually because a sequence on the new database is behind the data. Migrations often load the schema once, including sequences, while the source keeps running, then refresh only the table data at cutover. The tables end up with every row, but each sequence is still at the position it had at the initial load, so its next values are keys that already exist in the table. The first inserts that use those sequences fail with ORA-00001 unique constraint violated. Nothing is lost or corrupted; the sequences just need to be moved past the highest existing key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Data Pump migrate sequence values?
&lt;/h3&gt;

&lt;p&gt;Data Pump exports a sequence as a CREATE SEQUENCE statement whose START WITH value is the sequence's position at export time, specifically its LAST_NUMBER, which for a cached sequence is the top of the cache rather than the last value actually used. So a full export and import does carry the sequence position as of the moment of the export. What it cannot do is follow the source after that: if the source keeps generating values and you later refresh only table data (for example with CONTENT=DATA_ONLY), the target sequences stay at the old position.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I check whether sequences are behind after a migration?
&lt;/h3&gt;

&lt;p&gt;For each sequence that feeds a key, compare the value it will hand out next with the maximum key in the table it feeds. DBA_SEQUENCES.LAST_NUMBER gives the next value that has not been cached yet, and SELECT MAX(column) gives the highest existing key; if LAST_NUMBER is less than or equal to the maximum, the sequence is behind and will produce a duplicate key. Oracle does not record which sequence feeds which column unless the column uses it as a DEFAULT or is an identity column, so keep the sequence-to-column mapping in the cutover runbook and run the check as a go/no-go step before go-live.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I reset an Oracle sequence to a specific value?
&lt;/h3&gt;

&lt;p&gt;On Oracle 18c and later, use ALTER SEQUENCE owner.name RESTART START WITH n, where n is the next value you want handed out, typically the maximum existing key plus one. It resets the sequence in place and keeps its grants and dependents. On older releases, either temporarily set INCREMENT BY to the size of the gap, select NEXTVAL once, and set INCREMENT BY back to its original value, or drop and recreate the sequence with the right START WITH and re-grant it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about identity columns?
&lt;/h3&gt;

&lt;p&gt;Identity columns are backed by an internal sequence and have the same problem if the table data is reloaded without moving that sequence. Oracle provides a direct fix: ALTER TABLE table_name MODIFY column_name GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE) sets the identity's next value past the current maximum value in the column. Use the GENERATED clause that matches how the column is defined (ALWAYS or BY DEFAULT). You can see identity definitions in USER_TAB_IDENTITY_COLS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why didn't the problem show up in the migration rehearsal?
&lt;/h3&gt;

&lt;p&gt;Two reasons are common. First, rehearsals validate data (row counts, checksums, reconciliation), and the data really is correct; nothing compares sequence positions to keys. Second, cached sequences are exported at the top of their cache, which can be well ahead of the last value actually used, especially on releases that grow sequence caches dynamically. A rehearsal that generates only a little test activity between the initial load and the refresh never uses up that headroom, while days of production traffic do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I fix it by disabling the primary key constraint?
&lt;/h3&gt;

&lt;p&gt;No. Disabling or dropping the constraint makes the error disappear by allowing duplicate keys, which silently corrupts the data model and breaks anything that relies on the key being unique. The constraint is doing its job by refusing duplicates. The correct fix is to move the sequence past the highest existing key with ALTER SEQUENCE ... RESTART START WITH max plus one, and to make sequence resynchronization an explicit step in the cutover runbook.&lt;/p&gt;

&lt;p&gt;Sequence drift is the third of the migration failures that sail through a "successful" cutover, alongside &lt;a href="https://uptimearchitect.com/blog/oracle-data-pump-done-right/" rel="noopener noreferrer"&gt;Data Pump jobs nobody counted&lt;/a&gt; and &lt;a href="https://uptimearchitect.com/blog/oracle-character-set-migration-data-loss/" rel="noopener noreferrer"&gt;character-set conversions that quietly damage data&lt;/a&gt;. The pattern is the same each time: the tooling reports success, so verify the thing the tooling doesn't check. Choose the method with the &lt;a href="https://uptimearchitect.com/blog/oracle-cloud-migration-methods/" rel="noopener noreferrer"&gt;migration decision guide&lt;/a&gt;, then make sequence resync and its check explicit steps in the cutover runbook. And prove it the way that ends the argument: with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/sequence-cutover" rel="noopener noreferrer"&gt;sequence-cutover lab&lt;/a&gt;, where every row arrives intact and the first new order still fails until the sequence is moved.&lt;/p&gt;




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

</description>
      <category>oracle</category>
      <category>database</category>
      <category>migration</category>
      <category>devops</category>
    </item>
    <item>
      <title>You Deleted 99% of the Rows. The Full Scan Didn't Get Any Faster.</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Wed, 14 Oct 2026 19:45:45 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/you-deleted-99-of-the-rows-the-full-scan-didnt-get-any-faster-40mb</link>
      <guid>https://dev.to/uptimearchitect/you-deleted-99-of-the-rows-the-full-scan-didnt-get-any-faster-40mb</guid>
      <description>&lt;p&gt;The audit table had grown to 200 million rows, so the team finally ran the purge: everything older than 90 days, gone. Ninety-nine percent of the rows deleted, committed, celebrated. The next morning the nightly report that full-scans that table ran exactly as long as it always had. Same I/O, same elapsed time, same line in the AWR report. The data was gone. The work wasn't.&lt;/p&gt;

&lt;p&gt;Nothing is broken here, and the delete did what it was told. The full scan simply doesn't read &lt;em&gt;rows&lt;/em&gt; — it reads &lt;em&gt;blocks&lt;/em&gt;, every block up to a boundary called the &lt;strong&gt;high-water mark&lt;/strong&gt;, and &lt;code&gt;DELETE&lt;/code&gt; never moves that boundary. This post is what the high-water mark is, why a purge leaves it where it was, how to find tables carrying that dead weight, the fix, and a lab that measures the same scan before the delete, after it, and after the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the high-water mark is
&lt;/h2&gt;

&lt;p&gt;A table is stored in a &lt;strong&gt;segment&lt;/strong&gt;: a set of blocks the database allocates as the table grows. As rows are inserted, blocks get formatted and filled from the start of the segment outward. The &lt;strong&gt;high-water mark (HWM)&lt;/strong&gt; is the boundary between blocks that have ever been used and blocks that never have.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;full table scan&lt;/strong&gt; has no index telling it where the rows are, so it reads every block below the high-water mark and looks inside each one. Blocks above the HWM are skipped — they have never held data. Blocks below it are read whether they contain a thousand rows or none.&lt;/p&gt;

&lt;p&gt;The optimizer works the same way: it costs a full scan from the table's block count in its statistics. A table with a high HWM doesn't just &lt;em&gt;run&lt;/em&gt; like a big table, it &lt;em&gt;looks&lt;/em&gt; like one to the optimizer too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why DELETE doesn't lower it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DELETE&lt;/code&gt; removes rows from blocks. It does not hand the blocks back, and it does not move the high-water mark. The emptied blocks stay part of the segment, below the HWM, marked as having free space so that future inserts can reuse them. That is a sensible design for a table that will grow back — but for a table that was purged and won't, every full scan now reads thousands of blocks that are empty, or nearly so.&lt;/p&gt;

&lt;p&gt;So the table's cost to scan is set by its &lt;strong&gt;largest-ever size&lt;/strong&gt;, not its current size. A purge that removes 99% of the rows removes 0% of the full-scan work.&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%3AeNp1kU9Pg0AQxb_KyyaepBWotmljmmAhoRHUFBIP4mFhF9p0YQ0s6aGpn70LNBo13mYyb95v_hxJJhknC5BcyEO2pbVC7CYVELwlJJCUwTZNwzRN1PLQJOQdo9ESvqWr_msIqvA5NazZHVIhs31zn9Y3y7wVAk1GqwWmhjm7RcFV19rZ-lZv4Op-1wu82MN8foVrrJ7DcB1fRO4AsbXI_mJD8FwZ6KhtpSetCs5-4xq10_FfqN0bRtrPCWJvg9h5CDyMx2NE_mb99IjoxVl5F3U00Cda3XM_aLbnbCDrI_GaMyip97b_W_oHPBO0aboRUsq-88jwJyikZMQAKXld0h3TbzgSteVl_xDGc9oKRU6nM5BkfMs" 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%3AeNp1kU9Pg0AQxb_KyyaepBWotmljmmAhoRHUFBIP4mFhF9p0YQ0s6aGpn70LNBo13mYyb95v_hxJJhknC5BcyEO2pbVC7CYVELwlJJCUwTZNwzRN1PLQJOQdo9ESvqWr_msIqvA5NazZHVIhs31zn9Y3y7wVAk1GqwWmhjm7RcFV19rZ-lZv4Op-1wu82MN8foVrrJ7DcB1fRO4AsbXI_mJD8FwZ6KhtpSetCs5-4xq10_FfqN0bRtrPCWJvg9h5CDyMx2NE_mb99IjoxVl5F3U00Cda3XM_aLbnbCDrI_GaMyip97b_W_oHPBO0aboRUsq-88jwJyikZMQAKXld0h3TbzgSteVl_xDGc9oKRU6nM5BkfMs" alt="A full table scan reads every block below the high-water mark, empty or not. Loading 200,0" width="276" height="734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A full table scan reads every block below the high-water mark, empty or not. Loading 200,000 rows pushes the HWM out to about 6,175 blocks. Deleting 99% of the rows empties those blocks but leaves the HWM where it was, so the same scan still reads about 6,000 blocks to find 2,000 rows. SHRINK SPACE packs the survivors into the start of the segment and lowers the HWM, and the scan drops to about 60 blocks.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it
&lt;/h2&gt;

&lt;p&gt;The lab loads a 200,000-row table (about 200 bytes a row) and measures one full scan — &lt;code&gt;SELECT /*+ FULL(t) */ COUNT(pad) FROM hwm.t&lt;/code&gt; — in consistent gets, the logical block reads the scan performs, taken from &lt;code&gt;V$MYSTAT&lt;/code&gt;. Then it deletes 99% of the rows, commits, and runs the &lt;em&gt;same&lt;/em&gt; scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                       rows   full-scan gets   blocks below HWM
loaded              200,000            6,074              6,175
after DELETE 99%      2,000            6,074              6,175
after SHRINK SPACE    2,000               64                 62
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One percent of the rows, one hundred percent of the work — until the high-water mark comes down. After the shrink, the scan's cost follows the data: 64 gets for 2,000 rows.&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/hwm-bloat" rel="noopener noreferrer"&gt;high-water mark lab&lt;/a&gt; loads the table on Oracle Database Free, measures the full scan, deletes 99% of the rows and &lt;strong&gt;asserts&lt;/strong&gt; the scan still costs at least 90% of the baseline, then runs &lt;code&gt;SHRINK SPACE&lt;/code&gt; and asserts it drops to 5% or less with every remaining row intact. If the bloat doesn't reproduce, or the shrink doesn't fix it, the run fails. Proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How to find bloated tables
&lt;/h2&gt;

&lt;p&gt;You don't need to guess. Compare what a table occupies with what its rows actually need, using fresh optimizer statistics in &lt;code&gt;DBA_TABLES&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&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;blocks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;CEIL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num_rows&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;avg_row_len&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocks_needed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num_rows&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;avg_row_len&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                        &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;                              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct_wasted&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;dba_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'APP'&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt;    &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&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;blocks&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;CEIL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num_rows&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;avg_row_len&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;DESC&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;blocks&lt;/code&gt; is the space below the high-water mark; &lt;code&gt;blocks_needed&lt;/code&gt; estimates the space the current rows would fill at about 90% density (adjust 8192 if your block size differs). In the lab, after the delete, this reports &lt;strong&gt;6,175 blocks&lt;/strong&gt; for rows that need about &lt;strong&gt;58&lt;/strong&gt; — 99% wasted. Two caveats: it is only as current as the statistics, so gather first; and it is an estimate, so treat it as a ranking of where to look, not a precise measurement. For precise per-block fullness, &lt;code&gt;DBMS_SPACE.SPACE_USAGE&lt;/code&gt; reports how many blocks are empty, 0–25% full, and so on; the database's automatic &lt;strong&gt;Segment Advisor&lt;/strong&gt; also flags segments with reclaimable space.&lt;/p&gt;

&lt;p&gt;Prioritise tables that are both bloated and &lt;strong&gt;full-scanned&lt;/strong&gt;: check AWR or &lt;code&gt;V$SQL_PLAN&lt;/code&gt; for &lt;code&gt;TABLE ACCESS FULL&lt;/code&gt; on them. A bloated table that is only ever read by primary key costs you disk space, not query time.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SHRINK SPACE&lt;/code&gt;&lt;/strong&gt; compacts the rows into the start of the segment, lowers the high-water mark and releases the freed space:&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;TABLE&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;audit_log&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="n"&gt;MOVEMENT&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;TABLE&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;audit_log&lt;/span&gt; &lt;span class="n"&gt;SHRINK&lt;/span&gt; &lt;span class="k"&gt;SPACE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;It needs &lt;strong&gt;row movement&lt;/strong&gt;, because rows are physically relocated and get new ROWIDs.&lt;/li&gt;
&lt;li&gt;It works on tables in &lt;strong&gt;ASSM&lt;/strong&gt; (automatic segment space management) tablespaces, which is the default.&lt;/li&gt;
&lt;li&gt;It is &lt;strong&gt;online&lt;/strong&gt;: DML continues during the compaction, with only a brief lock at the end to move the HWM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indexes stay usable&lt;/strong&gt; and are maintained as rows move. Add &lt;code&gt;CASCADE&lt;/code&gt; to shrink dependent indexes too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SHRINK SPACE COMPACT&lt;/code&gt;&lt;/strong&gt; does the row movement but stops short of moving the HWM, so you can split the work: compact during the day, then run a quick &lt;code&gt;SHRINK SPACE&lt;/code&gt; in a quiet window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ALTER TABLE … MOVE ONLINE&lt;/code&gt;&lt;/strong&gt; (12.2 and later) is the alternative: it rebuilds the table into a new segment, keeps indexes usable, and allows DML throughout, at the cost of needing space for a second copy while it runs. A plain &lt;code&gt;MOVE&lt;/code&gt; without &lt;code&gt;ONLINE&lt;/code&gt; blocks DML and leaves every index &lt;code&gt;UNUSABLE&lt;/code&gt; until rebuilt — a common way to turn a cleanup into an outage. A handful of segment types can't be shrunk; &lt;code&gt;MOVE ONLINE&lt;/code&gt; is the usual fallback.&lt;/p&gt;

&lt;p&gt;After either one, &lt;strong&gt;gather statistics&lt;/strong&gt;, so the optimizer sees the new, smaller block count.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expecting &lt;code&gt;DELETE&lt;/code&gt; to make things faster.&lt;/strong&gt; It reduces rows, not blocks. Full scans, and the optimizer's costing of them, keep paying for the table's largest-ever size until the HWM is lowered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running the purge as one giant &lt;code&gt;DELETE&lt;/code&gt; every time.&lt;/strong&gt; For data that ages out by date, partition by date and drop or truncate old partitions instead: it releases the space immediately, generates almost no undo, and lets queries skip old data entirely with &lt;a href="https://uptimearchitect.com/blog/oracle-partition-pruning/" rel="noopener noreferrer"&gt;partition pruning&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using &lt;code&gt;MOVE&lt;/code&gt; without &lt;code&gt;ONLINE&lt;/code&gt;.&lt;/strong&gt; Every index on the table goes &lt;code&gt;UNUSABLE&lt;/code&gt;, and queries relying on them fail or fall back to full scans until someone rebuilds them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shrinking tables that will grow straight back.&lt;/strong&gt; Conventional inserts reuse the free space below the HWM. If a table is purged and then refilled on a cycle, shrinking it just makes the next cycle re-allocate the same space. Shrink tables that were purged and will stay small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting direct-path loads.&lt;/strong&gt; &lt;code&gt;INSERT /*+ APPEND */&lt;/code&gt; always writes above the high-water mark and never reuses free space below it. A table loaded by direct path and purged by &lt;code&gt;DELETE&lt;/code&gt; grows forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping the statistics afterwards.&lt;/strong&gt; Until stats are refreshed, the optimizer still believes the table is thousands of blocks and costs plans accordingly.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What is the high-water mark in Oracle?
&lt;/h3&gt;

&lt;p&gt;The high-water mark (HWM) is the boundary in a table's segment between blocks that have been used to store data at some point and blocks that never have. As rows are inserted, the database formats and fills blocks from the start of the segment outward, pushing the high-water mark up. A full table scan reads every block below the high-water mark, whether those blocks currently contain rows or are empty, and skips the blocks above it. The optimizer also uses the table's block count from statistics, which reflects the space below the high-water mark, to cost full scans.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why doesn't DELETE make a full table scan faster?
&lt;/h3&gt;

&lt;p&gt;Because DELETE removes rows from blocks but does not release the blocks or lower the high-water mark. The emptied blocks remain part of the table's segment, below the high-water mark, marked as having free space for future inserts. A full table scan reads every block below the high-water mark, so after deleting 99% of the rows it still reads almost exactly the same number of blocks. In the companion lab the same full scan costs 6,074 consistent gets with 200,000 rows and still 6,074 after deleting all but 2,000 of them. Only lowering the high-water mark, with SHRINK SPACE, MOVE, or TRUNCATE, reduces the scan's work.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I lower the high-water mark of an Oracle table?
&lt;/h3&gt;

&lt;p&gt;The usual online method is ALTER TABLE ... ENABLE ROW MOVEMENT followed by ALTER TABLE ... SHRINK SPACE, which compacts the rows toward the start of the segment, lowers the high-water mark, and releases the freed space while allowing concurrent DML. It requires an ASSM tablespace, which is the default. Alternatively, ALTER TABLE ... MOVE ONLINE (12.2 and later) rebuilds the table into a new segment while keeping indexes usable. TRUNCATE also resets the high-water mark instantly but removes every row. After any of these, gather optimizer statistics so plans reflect the smaller table.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between SHRINK SPACE and SHRINK SPACE COMPACT?
&lt;/h3&gt;

&lt;p&gt;SHRINK SPACE does two phases in one command: it moves rows toward the beginning of the segment, then lowers the high-water mark and releases the space, taking a brief lock at the end to do so. SHRINK SPACE COMPACT performs only the first phase, the row movement, and leaves the high-water mark where it is. That lets you split the work: run the compaction, which can take a while on a large table, during normal hours, and later run a plain SHRINK SPACE in a quiet window, which then completes quickly because the rows are already packed. Adding CASCADE also shrinks the table's dependent indexes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does SHRINK SPACE lock the table or invalidate indexes?
&lt;/h3&gt;

&lt;p&gt;SHRINK SPACE is an online operation: queries and DML can continue while rows are being compacted, and only a short exclusive lock is taken at the end to adjust the high-water mark. Indexes remain usable because they are maintained as rows move to new locations. Row movement must be enabled first because rows receive new ROWIDs, so anything that stores ROWIDs long-term would need to tolerate that. This differs from a plain ALTER TABLE ... MOVE without the ONLINE keyword, which blocks DML and marks every index on the table UNUSABLE until it is rebuilt.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can I find tables with a lot of wasted space below the high-water mark?
&lt;/h3&gt;

&lt;p&gt;After gathering fresh statistics, compare each table's BLOCKS in DBA_TABLES, which is the space below the high-water mark, with the space its rows need, estimated as NUM_ROWS times AVG_ROW_LEN divided by the usable bytes per block (for example 8192 times 0.9). Tables where BLOCKS is far larger than that estimate are carrying dead space; in the companion lab a purged table shows 6,175 blocks against about 58 needed. DBMS_SPACE.SPACE_USAGE gives an exact breakdown of how full the blocks are, and the automatic Segment Advisor reports segments with reclaimable space. Focus on bloated tables that are also read by full scans, since those are where the space turns into query time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I shrink every table after deleting rows?
&lt;/h3&gt;

&lt;p&gt;No. Conventional inserts reuse the free space below the high-water mark, so a table that is regularly purged and then refilled will simply grow back into the same space, and shrinking it only adds work. Shrinking pays off for tables that were purged and will stay much smaller, and that are read by full scans. For data that ages out by date on a schedule, partitioning by date and dropping or truncating old partitions is usually better than repeated DELETE plus SHRINK: it releases space immediately, generates little undo and redo, and lets queries skip old partitions entirely.&lt;/p&gt;

&lt;p&gt;High-water mark bloat is the same lesson the rest of the performance series keeps arriving at from different directions: the cost of a query is the work the engine actually does, not the size of the answer. &lt;a href="https://uptimearchitect.com/blog/oracle-execution-plans-decoded/" rel="noopener noreferrer"&gt;Reading the execution plan&lt;/a&gt; shows you a &lt;code&gt;TABLE ACCESS FULL&lt;/code&gt;; &lt;a href="https://uptimearchitect.com/blog/oracle-indexes-help-vs-hurt/" rel="noopener noreferrer"&gt;the right index&lt;/a&gt; avoids reading the whole table at all; &lt;a href="https://uptimearchitect.com/blog/oracle-partition-pruning/" rel="noopener noreferrer"&gt;partition pruning&lt;/a&gt; skips the parts you don't need; and &lt;a href="https://uptimearchitect.com/blog/oracle-optimizer-statistics-demystified/" rel="noopener noreferrer"&gt;fresh statistics&lt;/a&gt; make sure the optimizer is costing the table it actually has. When a purge didn't make anything faster, check the high-water mark: find the tables whose blocks dwarf their rows, shrink the ones that are full-scanned and won't grow back, and prove it the way that ends the argument — with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/hwm-bloat" rel="noopener noreferrer"&gt;high-water mark lab&lt;/a&gt;, where deleting 99% of a table changes nothing until the shrink drops the same scan from 6,074 gets to 64.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-high-water-mark-bloat/" 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>My weather app said the sky would be clear. I taught an open model to check, then went outside</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sun, 11 Oct 2026 02:54:00 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/my-weather-app-said-the-sky-would-be-clear-i-taught-an-open-model-to-check-then-went-outside-ebb</link>
      <guid>https://dev.to/uptimearchitect/my-weather-app-said-the-sky-would-be-clear-i-taught-an-open-model-to-check-then-went-outside-ebb</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every stargazer knows this evening. The app says &lt;em&gt;clear&lt;/em&gt;. You find the warm jacket, drive out past the streetlights, let your eyes adjust for twenty minutes… and look up at a flat grey lid.&lt;/p&gt;

&lt;p&gt;Cloud cover is the one number that decides whether going outside at night is worth it, and it's the number weather apps get wrong most often. I run systems for a living, and when a monitor keeps paging you wrongly you don't stop going outside. You measure how wrong it is, and you correct for it.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Clear Tonight?&lt;/strong&gt;: it takes the cloud forecast your app shows, looks up 32 months of that same forecast's track record at the same spot, and answers one question with an open model: &lt;em&gt;is the sky really going to be clear tonight, and when should I go?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; over a year of nights in 6 cities, it cuts the forecast's error by 28–41%. With only a week of history, TabPFN v2 is the only 12-feature model that beats the app. And on Saturday I committed a forecast, went outside, and the sky was clear.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A live page for 6 cities&lt;/strong&gt; (Seattle, Minneapolis, Chicago, Denver, Austin, London), refreshed every day. For tonight it shows a verdict (&lt;em&gt;Go 21:00–23:00&lt;/em&gt; or &lt;em&gt;Probably not tonight&lt;/em&gt;), an hour-by-hour strip of &lt;em&gt;what your app says&lt;/em&gt; against &lt;em&gt;the real chance it's clear&lt;/em&gt;, and the Moon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A local version for your own backyard:&lt;/strong&gt; &lt;code&gt;python -m clear_tonight --lat … --lon …&lt;/code&gt; downloads your spot's history, trains on your machine in about 20 seconds, and a local &lt;strong&gt;Gemma 4&lt;/strong&gt; turns the numbers into a short plan ("…about a 97% chance of really clear skies between 20:00 and 05:00. Since the Moon is 0% lit, it's a great night for faint stars.").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sky-photo log:&lt;/strong&gt; point it at a photo you took outside and Gemma 4 rates the cloud cover, so you slowly build your own ground truth.&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%2F47dwfkfaqselai0zok5f.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%2F47dwfkfaqselai0zok5f.png" alt="Clear Tonight for Minneapolis on Oct 10: Go 20:00 to 05:00, with the hourly strip of app cloud vs real clear chance"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screen part takes about five seconds. Everything else is outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://clear-tonight.onrender.com/#minneapolis" rel="noopener noreferrer"&gt;clear-tonight.onrender.com&lt;/a&gt;: pick a city to see tonight's verdict, the hour-by-hour odds, and the backtest for that city.&lt;/p&gt;

&lt;p&gt;An 80-second walkthrough, from the backyard to the backtest:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Qw1fkWXfr4o" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/pyaroslav" rel="noopener noreferrer"&gt;
        pyaroslav
      &lt;/a&gt; / &lt;a href="https://github.com/pyaroslav/clear-tonight" rel="noopener noreferrer"&gt;
        clear-tonight
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Will the sky really be clear tonight? Your weather app's cloud forecast, corrected by TabPFN v2 from its own track record. Local Gemma 4 for the plan.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Clear Tonight?&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/pyaroslav/clear-tonight/actions/workflows/tests.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/pyaroslav/clear-tonight/actions/workflows/tests.yml/badge.svg" alt="tests"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Will the sky really be clear tonight?&lt;/strong&gt; Your weather app's cloud forecast, corrected by an open model that learned from the forecast's own track record at your spot.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://clear-tonight.onrender.com" rel="nofollow noopener noreferrer"&gt;https://clear-tonight.onrender.com&lt;/a&gt; (6 cities, refreshed daily)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Demo video (80 s):&lt;/strong&gt; &lt;a href="https://youtu.be/Qw1fkWXfr4o" rel="nofollow noopener noreferrer"&gt;https://youtu.be/Qw1fkWXfr4o&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Cloud cover decides whether a night walk, a meteor shower or a look at the stars is worth getting off the couch for, and it's the forecast variable weather apps get wrong most often. Clear Tonight takes the forecast that was issued about 24 hours ahead, compares 32 months of those forecasts against what actually happened at the same place, and lets &lt;strong&gt;TabPFN v2&lt;/strong&gt; learn the pattern of its mistakes. Then it answers one question for tonight: &lt;em&gt;go or don't go, and when?&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live page (6 cities, refreshed daily):&lt;/strong&gt; see &lt;code&gt;site/&lt;/code&gt;. Deployed as a static site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own spot, fully local:&lt;/strong&gt; &lt;code&gt;python -m clear_tonight --lat .. --lon ..&lt;/code&gt;…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/pyaroslav/clear-tonight" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The question, as data
&lt;/h3&gt;

&lt;p&gt;Open-Meteo keeps an archive of what weather models &lt;em&gt;predicted&lt;/em&gt;, including the forecast issued about 24 hours ahead (&lt;code&gt;previous_day1&lt;/code&gt;) and 48 hours ahead (&lt;code&gt;previous_day2&lt;/code&gt;), and a separate archive of what &lt;em&gt;happened&lt;/em&gt; (ERA5 reanalysis). For each city I pulled every night hour (8 PM to 4 AM) from February 2024, when the day-ahead archive begins, to last week: about 975 nights and 8,700 hours per city.&lt;/p&gt;

&lt;p&gt;Each hour becomes a row of 12 things you'd know the evening before:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the forecast cloud cover;&lt;/li&gt;
&lt;li&gt;humidity and the temperature–dewpoint gap;&lt;/li&gt;
&lt;li&gt;wind and precipitation;&lt;/li&gt;
&lt;li&gt;what the &lt;em&gt;older&lt;/em&gt; forecast said, and how much it changed between runs;&lt;/li&gt;
&lt;li&gt;the forecast's view of the whole night;&lt;/li&gt;
&lt;li&gt;the hour, and the season.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The label is simple: was the sky actually clear (cloud cover ≤ 25%)?&lt;/p&gt;

&lt;h3&gt;
  
  
  Why TabPFN
&lt;/h3&gt;

&lt;p&gt;TabPFN is a foundation model for tables. Instead of training a model from scratch, you hand it your rows and it predicts in a single forward pass, having learned on millions of synthetic datasets how small tables usually behave. That's exactly the shape of this problem: one small table per place, no time to tune anything, and the ground truth that matters most, your own sky, starts with a handful of nights.&lt;/p&gt;

&lt;p&gt;I used &lt;strong&gt;TabPFN v2&lt;/strong&gt; deliberately. It's the version released under an Apache-2.0-based license (Prior Labs License 1.1, which adds a "Built with PriorLabs-TabPFN" attribution). The newer versions are non-commercial and need a login. v2 runs fully locally on a GPU, or on a CPU for a table this size.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honest backtest
&lt;/h3&gt;

&lt;p&gt;Walk-forward over the last 12 months: for every month, train only on what came before it and predict that month, the same way you'd use it live.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;City&lt;/th&gt;
&lt;th&gt;Brier: weather app&lt;/th&gt;
&lt;th&gt;Brier: Clear Tonight&lt;/th&gt;
&lt;th&gt;Error cut&lt;/th&gt;
&lt;th&gt;Wasted trips (app → us)&lt;/th&gt;
&lt;th&gt;Missed clear nights (app → us)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Austin&lt;/td&gt;
&lt;td&gt;0.214&lt;/td&gt;
&lt;td&gt;0.137&lt;/td&gt;
&lt;td&gt;36%&lt;/td&gt;
&lt;td&gt;27 → 20&lt;/td&gt;
&lt;td&gt;36 → 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chicago&lt;/td&gt;
&lt;td&gt;0.191&lt;/td&gt;
&lt;td&gt;0.130&lt;/td&gt;
&lt;td&gt;32%&lt;/td&gt;
&lt;td&gt;33 → 19&lt;/td&gt;
&lt;td&gt;37 → 53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Denver&lt;/td&gt;
&lt;td&gt;0.279&lt;/td&gt;
&lt;td&gt;0.164&lt;/td&gt;
&lt;td&gt;41%&lt;/td&gt;
&lt;td&gt;57 → 35&lt;/td&gt;
&lt;td&gt;37 → 48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;London&lt;/td&gt;
&lt;td&gt;0.194&lt;/td&gt;
&lt;td&gt;0.140&lt;/td&gt;
&lt;td&gt;28%&lt;/td&gt;
&lt;td&gt;16 → 10&lt;/td&gt;
&lt;td&gt;47 → 65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minneapolis&lt;/td&gt;
&lt;td&gt;0.233&lt;/td&gt;
&lt;td&gt;0.148&lt;/td&gt;
&lt;td&gt;36%&lt;/td&gt;
&lt;td&gt;29 → 21&lt;/td&gt;
&lt;td&gt;51 → 60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seattle&lt;/td&gt;
&lt;td&gt;0.185&lt;/td&gt;
&lt;td&gt;0.117&lt;/td&gt;
&lt;td&gt;37%&lt;/td&gt;
&lt;td&gt;26 → 12&lt;/td&gt;
&lt;td&gt;31 → 50&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(Brier score: lower is better. A &lt;em&gt;wasted trip&lt;/em&gt; is a night that said "go", meaning two clear hours in a row, and turned out cloudy.)&lt;/p&gt;

&lt;p&gt;The correction cuts the forecast's probability error by &lt;strong&gt;28–41% in every city&lt;/strong&gt;, and it roughly &lt;strong&gt;halves wasted trips&lt;/strong&gt; in Seattle and Chicago. The cost is real: it's more cautious, so it misses more clear nights than the app. For a 40-minute drive to a dark-sky park, that's the trade I want. If you'd rather never miss a clear night, use a lower threshold.&lt;/p&gt;

&lt;p&gt;And the part I didn't expect: &lt;strong&gt;on a full 32 months of history, a plain logistic regression does just as well as TabPFN v2.&lt;/strong&gt; If you have years of data for one place, you don't need a foundation model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where TabPFN actually earns its place
&lt;/h3&gt;

&lt;p&gt;The archive gives any spot on Earth 32 months of history, but its "truth" is ERA5: what happened over a grid cell roughly 30 km wide, not over your backyard or your favourite dark-sky pullout. The truth that really matters is your own sky, and that comes from the photo log one night at a time. So the question that decides which model to use is: what if you only have the last &lt;em&gt;K&lt;/em&gt; nights?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Nights of history&lt;/th&gt;
&lt;th&gt;TabPFN v2&lt;/th&gt;
&lt;th&gt;Logistic regression&lt;/th&gt;
&lt;th&gt;Gradient boosting&lt;/th&gt;
&lt;th&gt;Recalibrated app number (1 feature)&lt;/th&gt;
&lt;th&gt;Weather app as-is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0.214&lt;/td&gt;
&lt;td&gt;0.357&lt;/td&gt;
&lt;td&gt;0.259&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.199&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.237&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;0.204&lt;/td&gt;
&lt;td&gt;0.316&lt;/td&gt;
&lt;td&gt;0.277&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.183&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.237&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;0.186&lt;/td&gt;
&lt;td&gt;0.260&lt;/td&gt;
&lt;td&gt;0.248&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.179&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.237&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.176&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.199&lt;/td&gt;
&lt;td&gt;0.253&lt;/td&gt;
&lt;td&gt;0.179&lt;/td&gt;
&lt;td&gt;0.237&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;td&gt;0.171&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.161&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.237&lt;/td&gt;
&lt;td&gt;0.176&lt;/td&gt;
&lt;td&gt;0.237&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;365&lt;/td&gt;
&lt;td&gt;0.153&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.149&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.168&lt;/td&gt;
&lt;td&gt;0.174&lt;/td&gt;
&lt;td&gt;0.237&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(6 cities × 6 months, Brier score.)&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%2F4uti81ap6vnlw3n7fzo0.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%2F4uti81ap6vnlw3n7fzo0.png" alt="Brier score by nights of history: TabPFN v2 beats the weather app from the first week; logistic regression and gradient boosting need two to four months"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With a week of data, the classic models given the same 12 features are &lt;em&gt;worse than not correcting at all&lt;/em&gt;; they overfit. TabPFN v2, with the same 12 features, beats the raw forecast from the very first week, is the best model of all at two months, and is only overtaken by logistic regression after about four months.&lt;/p&gt;

&lt;p&gt;In fairness: for the first month, the simplest fix of all wins, which is just recalibrating the app's own cloud number (0.199 at 7 nights). So the honest recipe is: recalibrate on day one, and switch to TabPFN v2 at around two months. The app uses TabPFN from the start, because it's never worse than the raw forecast and it's the only model that gets better as you add the other 11 features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gemma 4: words, not numbers
&lt;/h3&gt;

&lt;p&gt;The local version asks Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt; via Ollama) to turn the hour-by-hour odds into a sentence. In my first try, Gemma read the table and told me the &lt;em&gt;worst&lt;/em&gt; two hours were the best, and that the app was more pessimistic than my correction, when it was the other way round. Lesson re-learned: code computes the facts (best window, which forecast is more optimistic, Moon phase) and Gemma only words them. Gemma also does one thing code can't: it looks at a phone photo of the sky and estimates the cloud cover, which becomes a ground-truth log. On the night it rated 3 of 4 photos correctly, and the fourth taught me something (below).&lt;/p&gt;

&lt;h3&gt;
  
  
  Plumbing
&lt;/h3&gt;

&lt;p&gt;A GitHub Action runs every afternoon. It trains TabPFN v2 on a CPU runner (a single ensemble member there; the backtest used the default four on a GPU), writes &lt;code&gt;tonight.json&lt;/code&gt;, and commits it. Render serves &lt;code&gt;site/&lt;/code&gt; as a static site, and the page reads the latest &lt;code&gt;tonight.json&lt;/code&gt; straight from the repo, so it's fresh without a redeploy. There's no backend and no API key, and the whole thing costs nothing to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking it outside
&lt;/h2&gt;

&lt;p&gt;On Saturday morning, Oct 10, I ran it for Minneapolis and &lt;a href="https://github.com/pyaroslav/clear-tonight/blob/main/field/2026-10-10-minneapolis.md" rel="noopener noreferrer"&gt;committed the forecast to the repo&lt;/a&gt; before going anywhere, so I couldn't quietly edit it afterwards. It was about as confident as it gets: &lt;strong&gt;96–98% chance of a clear sky every hour from 8 PM to 5 AM&lt;/strong&gt;, and a new Moon. Gemma's plan: &lt;em&gt;"Go outside tonight for stargazing… Since the Moon is 0% lit, it's a great night for faint stars. Remember to wear warm layers."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;(The warm layers were wrong. It was 80 °F in October.)&lt;/p&gt;

&lt;p&gt;At about 7:50 PM I went out to the backyard. The sky was clear from edge to edge, and the first stars were already out before full darkness. It was pleasantly warm, just right for relaxing; I barely moved. The crickets were calming, and the neighbourhood was fantastic.&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%2Fca2ittwjy7zxzvyezfpj.jpg" 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%2Fca2ittwjy7zxzvyezfpj.jpg" alt="Clear sky with stars, 7:51 PM"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The forecast was right&lt;/strong&gt;, and the weather model's own 15-minute record for the evening agrees: 0% cloud from 5:45 to 8 PM. One clear night under a confident forecast proves little; the backtest above is the real evidence. But it was worth going out, and that's the point of the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma rated 3 of 4 photos correctly, and the fourth taught me something.&lt;/strong&gt; I ran my sky photos through the photo log. Three came back right: 5–10% cloud, clear sky. The fourth was the same clear sky a few seconds later, but the phone's night mode had lifted the twilight and city glow into a flat grey. Gemma called it &lt;strong&gt;95% cloud, no stars, high confidence&lt;/strong&gt;, and said the same thing on three re-runs.&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%2Fuuhp96vmc2udmdz3p9ge.jpg" 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%2Fuuhp96vmc2udmdz3p9ge.jpg" alt="The same clear sky, which night mode turned grey; Gemma rated it 95% cloud"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's a useful thing to learn before building on the photo log. A small-data model trained on your own sky is only as good as its labels, and one confident wrong label is worth a lot in a table of 30 rows.&lt;/p&gt;

&lt;p&gt;So I fixed it that evening. The photo log now checks every rating against the other photos taken within 15 minutes and against the weather model's cloud cover for that hour. If a rating disagrees with both by 50 points or more, it's held back as &lt;em&gt;needs review&lt;/em&gt; instead of being trusted. Re-running the same four photos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sky 1     0% cloud, stars visible (high)     check: ok
sky 2    10% cloud, stars visible (medium)   check: ok
sky 3    95% cloud, no stars (high)          check: needs review: other photos ~5%, weather model 0%
backyard 10% cloud, no stars (medium)        check: ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the same lesson as the narration: let the model describe what it sees, but check it against what you already know.&lt;/p&gt;

&lt;p&gt;I also committed a forecast for the night before, Oct 9, and didn't go out. It will be scored against the final ERA5 data when that's published around Oct 14–15. The early estimates for that night disagree with each other, so I'm not calling it either way yet. I'll post the result in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, and what's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Truth" is ERA5, a reconstruction over a grid cell about 30 km wide,&lt;/strong&gt; not a person looking up from your backyard. That's why the photo log exists, but it doesn't train the model yet. Next: once a spot has a few dozen checked photos, fit TabPFN on those. That's exactly the small-table case the learning curve says it's good at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One forecast model.&lt;/strong&gt; The input is GFS's day-ahead forecast, because that's what the archive keeps for every day since February 2024. Other models might need less correcting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Clear" means 25% cloud or less, per hour.&lt;/strong&gt; Serious astrophotographers would want a stricter line, and casual stargazers a looser one. It's one constant in the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cautious by design.&lt;/strong&gt; It misses more clear nights than the app does, in exchange for fewer wasted trips.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One night outside proves nothing on its own.&lt;/strong&gt; The evidence is the 12-month backtest across 6 cities. The walk is the part a backtest can't show: was it worth going out? (Yes.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The forecast's track record is the product, and it's open too.&lt;/strong&gt; The whole project rests on Open-Meteo publishing not just today's forecast but every past forecast next to what really happened. Closed weather APIs give you a prediction; open data lets you audit it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An open tabular foundation model fits a one-backyard problem.&lt;/strong&gt; TabPFN v2's weights run on my desktop's GPU (or a free CI runner) with no account and no per-call cost. That's what makes "train on &lt;em&gt;your&lt;/em&gt; spot tonight" a 20-second command instead of a product decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your location stays yours.&lt;/strong&gt; The local version sends nothing but coordinates to the weather archive. The model, the plan and your sky photos stay on your machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could check the claims myself.&lt;/strong&gt; Because everything is open, I could measure exactly where TabPFN helps (little data) and where it doesn't (lots of data), and say so. A hosted black box would have given me a nice number and no idea whether to trust it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of TabPFN:&lt;/strong&gt; TabPFN v2 (Apache-2.0-based license) is the model. It's backtested walk-forward against the raw forecast, climatology, logistic regression and gradient boosting across 6 cities, with a learning curve showing where it wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render:&lt;/strong&gt; the live page is a free Render static site serving the repo's &lt;code&gt;site/&lt;/code&gt; folder (a &lt;code&gt;render.yaml&lt;/code&gt; Blueprint is included). It shows the forecast the GitHub Action makes with TabPFN v2 every day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma 4 runs locally to turn the forecast into a plan and to rate sky photos for ground truth, plus a cross-check I added on the night that catches the kind of photo it can misread.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Built with PriorLabs-TabPFN. Weather data by Open-Meteo.com (CC BY 4.0).&lt;/p&gt;

&lt;p&gt;— Yaro&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>tabpfn</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The Migration Completed Successfully. Half the Names Are Now Question Marks.</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Wed, 07 Oct 2026 11:08:58 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/the-migration-completed-successfully-half-the-names-are-now-question-marks-3b5i</link>
      <guid>https://dev.to/uptimearchitect/the-migration-completed-successfully-half-the-names-are-now-question-marks-3b5i</guid>
      <description>&lt;p&gt;The cutover ran clean. &lt;code&gt;impdp&lt;/code&gt; reported &lt;code&gt;completed successfully&lt;/code&gt;, the row counts matched to the row, the application came up, and everyone went home. Three weeks later a support ticket lands: a customer named &lt;code&gt;José&lt;/code&gt; is in the system as &lt;code&gt;Jose&lt;/code&gt;, and a partner in Tokyo shows up as &lt;code&gt;??&lt;/code&gt;. Not a rendering bug — the database really holds &lt;code&gt;Jose&lt;/code&gt; and &lt;code&gt;??&lt;/code&gt; now. The originals are gone, and no backup from after the cutover has them either.&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable part: &lt;strong&gt;nothing failed.&lt;/strong&gt; No error was raised, no row was rejected, no count was off. The migration did exactly what you told it to — you just told it to pour Unicode data into a container too narrow to hold it, and Oracle quietly trimmed everything that didn't fit. This post is what character-set data loss actually is, why every check you ran said "success," the one test that catches it, and a lab that reproduces the loss on demand and then shows the migration that keeps every character.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens
&lt;/h2&gt;

&lt;p&gt;A character set is the map between the characters you see and the bytes stored on disk. Oracle has two that matter here: the &lt;strong&gt;database character set&lt;/strong&gt; (&lt;code&gt;NLS_CHARACTERSET&lt;/code&gt;), which governs &lt;code&gt;VARCHAR2&lt;/code&gt;/&lt;code&gt;CHAR&lt;/code&gt; data, and the &lt;strong&gt;client's&lt;/strong&gt; declared character set (&lt;code&gt;NLS_LANG&lt;/code&gt;), which tells the server what the bytes arriving from a client &lt;em&gt;mean&lt;/em&gt;. Modern databases use &lt;strong&gt;AL32UTF8&lt;/strong&gt; — Unicode — which can represent every character in every language. Many older databases used a &lt;strong&gt;narrow&lt;/strong&gt; set: &lt;code&gt;US7ASCII&lt;/code&gt; (7-bit, 128 characters), &lt;code&gt;WE8MSWIN1252&lt;/code&gt; (Western European), &lt;code&gt;WE8ISO8859P1&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;When data moves from a wider character set into a narrower one — an export/import into a legacy target, a &lt;code&gt;CREATE TABLE AS&lt;/code&gt; across a database link, a client conversion — Oracle must map each character into the target set. For a character the target &lt;strong&gt;can&lt;/strong&gt; represent, fine. For one it &lt;strong&gt;can't&lt;/strong&gt;, it does one of two things, and both are data loss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Silent transliteration.&lt;/strong&gt; A character with a rough ASCII equivalent is replaced by that equivalent. &lt;code&gt;José&lt;/code&gt; becomes &lt;code&gt;Jose&lt;/code&gt;, &lt;code&gt;Müller&lt;/code&gt; becomes &lt;code&gt;Muller&lt;/code&gt;, &lt;code&gt;François&lt;/code&gt; becomes &lt;code&gt;Francois&lt;/code&gt;. It looks completely plausible — which is exactly why it sails through QA and lands in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replacement.&lt;/strong&gt; A character with no mapping at all becomes a replacement character, almost always &lt;code&gt;?&lt;/code&gt;. A CJK name becomes &lt;code&gt;??&lt;/code&gt;; an emoji or an unusual symbol becomes &lt;code&gt;?&lt;/code&gt;. Obvious once someone looks — but nobody looks at every row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Crucially, this conversion is a normal, "successful" operation. It does not raise an error. It does not reject the row. The row count is identical before and after. Every signal a migration runbook checks says the load worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every check said "success"
&lt;/h2&gt;

&lt;p&gt;The standard post-migration checks all operate at the wrong level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Row counts&lt;/strong&gt; compare &lt;em&gt;how many&lt;/em&gt; rows moved, not &lt;em&gt;what's in them&lt;/em&gt;. &lt;code&gt;José&lt;/code&gt; and &lt;code&gt;Jose&lt;/code&gt; are both one row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"0 errors" / &lt;code&gt;completed successfully&lt;/code&gt;&lt;/strong&gt; means no row was rejected. Character-set conversion isn't a rejection — it's a transformation the database is happy to perform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spot-checking&lt;/strong&gt; finds the &lt;code&gt;??&lt;/code&gt; names if you happen to look at one, but misses the transliterated ones entirely, because &lt;code&gt;Jose&lt;/code&gt; looks correct.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The loss is in the &lt;em&gt;values&lt;/em&gt;, so you have to compare the values — specifically, whether each value would survive a round-trip through the target character set. That's the check almost nobody runs, and it's the whole game.&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%3AeNp9kl9rIjEUxb_KJU8WlB2nK7ayVMZpF-yufeiM-LDpQya56tDMRPJHWazfvUmsq7SwEMjNTc45Py7ZE64EkhGQpVQ7vmbaQnlPW4DiDyWFcprjCLLf1-m8_HkDnXlbB8HVj0p_u3tUhrokwVsIWzWEWdiXXErUpxZ1w8F1Qt13TMWpVzS1XVPyAr3eHZR7SkqmV2ghxDNuvdigHVNyCBxlePVGybwYZkU-nUJn2Ktqe0XJG2Se8R8EdWn_NvVH7F6CHNszF-pIfUl0vByP48UA1BL6CbSsQQOCNWyFwmNeUiwebmbFYvrUTwcpdBZoPG0LD06rCDTxQIxzbK2BV9zY_wfmj79iGEhl7KegLyMH4zao_WBiUO6DcIv678XQNhoN6i2KaJ4EV_PhmsVRPwc6KUHjRmk7Aq12Bhpm-boLCaDWSpuo9W-DGGq_2m1t6koiWOVh6y36ROSvJ-fJ0TmU-bnkknl5BhUT5-Ok-ww7pttzJ4eVUoJ0gTSoG1YL_xH3xK6xiV9S4JI5acnh8A5kjdUe" 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%3AeNp9kl9rIjEUxb_KJU8WlB2nK7ayVMZpF-yufeiM-LDpQya56tDMRPJHWazfvUmsq7SwEMjNTc45Py7ZE64EkhGQpVQ7vmbaQnlPW4DiDyWFcprjCLLf1-m8_HkDnXlbB8HVj0p_u3tUhrokwVsIWzWEWdiXXErUpxZ1w8F1Qt13TMWpVzS1XVPyAr3eHZR7SkqmV2ghxDNuvdigHVNyCBxlePVGybwYZkU-nUJn2Ktqe0XJG2Se8R8EdWn_NvVH7F6CHNszF-pIfUl0vByP48UA1BL6CbSsQQOCNWyFwmNeUiwebmbFYvrUTwcpdBZoPG0LD06rCDTxQIxzbK2BV9zY_wfmj79iGEhl7KegLyMH4zao_WBiUO6DcIv678XQNhoN6i2KaJ4EV_PhmsVRPwc6KUHjRmk7Aq12Bhpm-boLCaDWSpuo9W-DGGq_2m1t6koiWOVh6y36ROSvJ-fJ0TmU-bnkknl5BhUT5-Ok-ww7pttzJ4eVUoJ0gTSoG1YL_xH3xK6xiV9S4JI5acnh8A5kjdUe" alt="The same Unicode AL32UTF8 rows loaded into three target character sets. Every path reports" width="782" height="748"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same Unicode (AL32UTF8) rows loaded into three target character sets. Every path reports success and keeps all the rows -- the difference is only in the values. A US7ASCII target strips Latin accents to plain ASCII (Jose) and turns anything with no mapping into '?'; a WE8MSWIN1252 (Western European) target keeps the accents but still loses the CJK name; only a Unicode (AL32UTF8) target, a superset, preserves every character. Row counts match and no error is raised in all three cases.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;You don't need a real migration to see this. Build a 10-row &lt;code&gt;customers&lt;/code&gt; table in an AL32UTF8 database — five plain-ASCII names and five with accents or CJK characters — then model loading it into a narrower target with &lt;code&gt;CONVERT(name, target_charset, 'AL32UTF8')&lt;/code&gt;, which does exactly what the target database's conversion does on import. Migrating into &lt;strong&gt;US7ASCII&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rows after migration: 10   errors raised: 0
names damaged by a US7ASCII target: 5 of 10   (of those, turned into '?': 1)
names still damaged by a WE8MSWIN1252 (Western European) target: 1
   José         -&amp;gt;  Jose     (US7ASCII target)
   Müller       -&amp;gt;  Muller   (US7ASCII target)
   Renée        -&amp;gt;  Renee    (US7ASCII target)
   François     -&amp;gt;  Francois (US7ASCII target)
   田中          -&amp;gt;  ??       (US7ASCII target)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten rows in, ten rows out, zero errors — and half the names quietly damaged. Four were transliterated into plausible-but-wrong ASCII; one became &lt;code&gt;??&lt;/code&gt;. Note the middle row of the story: even a &lt;strong&gt;Western European&lt;/strong&gt; target (&lt;code&gt;WE8MSWIN1252&lt;/code&gt;), which happily keeps &lt;code&gt;José&lt;/code&gt; and &lt;code&gt;Müller&lt;/code&gt;, still turns the CJK name into &lt;code&gt;??&lt;/code&gt;. "Wide enough for our data" is a judgment that ages badly the moment you onboard a customer from a new region.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detect it — before cutover
&lt;/h2&gt;

&lt;p&gt;The test that catches what row counts miss is a &lt;strong&gt;round-trip&lt;/strong&gt;: convert each value to the target character set and back, and compare it to the original. Anything that comes back changed would be damaged by the migration:&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;-- pre-migration scan: which rows would a US7ASCII target silently damage?&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt;   &lt;span class="n"&gt;customers&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt;  &lt;span class="k"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'US7ASCII'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'AL32UTF8'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="s1"&gt;'AL32UTF8'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'US7ASCII'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the lab this flags exactly the five non-ASCII rows, in advance. This is precisely what Oracle's &lt;strong&gt;Database Migration Assistant for Unicode (DMU)&lt;/strong&gt; — and the legacy &lt;strong&gt;&lt;code&gt;csscan&lt;/code&gt;&lt;/strong&gt; utility — do for real: scan every column of every table and report the data that is &lt;em&gt;lossy&lt;/em&gt; (can't be represented in the target) or would need &lt;em&gt;byte expansion&lt;/em&gt;. Run it against the source &lt;strong&gt;before&lt;/strong&gt; you migrate, not against the wreckage afterward.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Migrate into a Unicode target (AL32UTF8).&lt;/strong&gt; Unicode is a superset of every legacy Oracle character set, so every source character has a representation and nothing is lost. In the lab, the same data migrated into AL32UTF8 has a round-trip loss of &lt;strong&gt;zero&lt;/strong&gt;. If you're modernizing anyway, this is the answer: go to AL32UTF8 and stop having this problem forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you must land in a narrower set, scan first and remediate.&lt;/strong&gt; Use DMU/&lt;code&gt;csscan&lt;/code&gt; to find lossy data, then fix it at the source (correct the values, or widen the target) before cutover — never discover it after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the client &lt;code&gt;NLS_LANG&lt;/code&gt; correctly on every tool in the pipeline.&lt;/strong&gt; More on this trap below; a wrong &lt;code&gt;NLS_LANG&lt;/code&gt; corrupts data even when &lt;em&gt;both&lt;/em&gt; databases are Unicode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch column length semantics when migrating &lt;em&gt;up&lt;/em&gt; to Unicode.&lt;/strong&gt; Going from a single-byte set to AL32UTF8, characters that were one byte can become up to four. A &lt;code&gt;VARCHAR2(10 BYTE)&lt;/code&gt; column that held a 10-character name may no longer fit — the import fails with &lt;code&gt;ORA-12899&lt;/code&gt;, or worse, silently truncates. Define columns with &lt;code&gt;CHAR&lt;/code&gt; semantics (&lt;code&gt;VARCHAR2(10 CHAR)&lt;/code&gt;) or size for the byte expansion.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trusting the row count and the "completed successfully".&lt;/strong&gt; Both are true and both are irrelevant to character-set loss. The count tells you rows moved; it tells you nothing about whether the values survived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thinking &lt;code&gt;?&lt;/code&gt; is the only symptom.&lt;/strong&gt; The &lt;code&gt;?&lt;/code&gt;-marks are the &lt;em&gt;lucky&lt;/em&gt; case — they're visible. The silent transliteration (&lt;code&gt;José&lt;/code&gt;→&lt;code&gt;Jose&lt;/code&gt;) is the dangerous one: it looks right, passes review, and you find out when a customer complains that you've been spelling their name wrong for a month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;NLS_LANG&lt;/code&gt; pass-through trap.&lt;/strong&gt; The other classic corruption isn't a narrow &lt;em&gt;database&lt;/em&gt; — it's a wrong &lt;em&gt;client&lt;/em&gt;. If a tool inserts UTF-8 bytes while its &lt;code&gt;NLS_LANG&lt;/code&gt; claims &lt;code&gt;US7ASCII&lt;/code&gt;, Oracle believes the lie, does no conversion (or the wrong one), and stores garbage — even when the database is AL32UTF8. "Both sides are Unicode" does not save you if a client in the middle misdeclares its character set. Set &lt;code&gt;NLS_LANG&lt;/code&gt; on every client, exporter, and loader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming "Western European is wide enough".&lt;/strong&gt; It holds accented Latin, so it survives a demo full of French and German names — and destroys the first Japanese, Greek, Arabic, or emoji-bearing value it meets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrating first, scanning never.&lt;/strong&gt; DMU/&lt;code&gt;csscan&lt;/code&gt; exist to be run &lt;em&gt;before&lt;/em&gt; the migration. Skipping the scan is choosing to find your data loss in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No backup of the pre-migration source.&lt;/strong&gt; Character-set loss is irreversible: once &lt;code&gt;José&lt;/code&gt; is stored as &lt;code&gt;Jose&lt;/code&gt;, the accent is gone. If you didn't scan and you didn't keep the source, there is nothing to recover from. Keep the source readable until you've verified the target at the value level.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  What causes Oracle character-set data loss during a migration?
&lt;/h3&gt;

&lt;p&gt;It happens when data moves from a wider character set into one that cannot represent all of its characters -- most commonly Unicode (AL32UTF8) data being loaded into a narrow legacy set like US7ASCII or WE8MSWIN1252. Oracle must map each character into the target set, and for characters the target cannot hold it either transliterates them to a rough ASCII equivalent (José becomes Jose, the accent silently dropped) or replaces them with a replacement character, usually '?' (a CJK name becomes ??). This conversion is treated as a normal, successful operation: no error is raised, no row is rejected, and the row count is unchanged, so it passes every count-based migration check while quietly destroying data. A separate but related cause is a mismatched client NLS_LANG, where a tool misdeclares the character set of the bytes it is sending and Oracle stores them incorrectly even when both databases are Unicode.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did my migration report success but the data is corrupted?
&lt;/h3&gt;

&lt;p&gt;Because character-set conversion is not an error condition. The checks a migration runbook relies on -- 'completed successfully', zero rejected rows, and matching source and target row counts -- all operate on rows and errors, not on the contents of the values. When Oracle converts a character it cannot represent in the target set, it transforms the value (to a transliteration or a '?') rather than failing, so every row still loads, no error is logged, and the counts match exactly. The loss lives inside the values, which none of those checks inspect. To see it you have to compare the actual values, typically with a round-trip conversion test, rather than trusting the success message.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I check for character-set data loss before migrating?
&lt;/h3&gt;

&lt;p&gt;Run a round-trip test: convert each value to the target character set and back to the source set, and compare it to the original -- any value that comes back changed cannot survive the target set. In SQL that is a query like WHERE CONVERT(CONVERT(col,'US7ASCII','AL32UTF8'),'AL32UTF8','US7ASCII') &amp;lt;&amp;gt; col, run against every character column. For a real migration, use Oracle's Database Migration Assistant for Unicode (DMU) or the legacy csscan utility, which scan every column of every table and report which data is lossy (cannot be represented) or would need byte expansion. The essential discipline is to run this against the source before cutover, not to discover the damage in production afterward.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between transliteration and '?' replacement?
&lt;/h3&gt;

&lt;p&gt;They are the two ways Oracle handles a character the target set cannot represent, and both are data loss. Transliteration substitutes a rough equivalent that the target set does have -- José becomes Jose, Müller becomes Muller -- so the result is readable and plausible but wrong; this is the more dangerous case because it passes visual review and QA, and you may not notice for weeks. Replacement is used when there is no equivalent at all: the character becomes a replacement character, almost always '?', so a CJK or Arabic name becomes a string of question marks. Replacement is obvious once someone looks at the row; transliteration is silent. Both are irreversible once the original value has been overwritten.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does migrating to AL32UTF8 (Unicode) cause data loss?
&lt;/h3&gt;

&lt;p&gt;Migrating into AL32UTF8 does not lose characters, because Unicode is a superset of every legacy Oracle character set -- every source character has a representation, so the conversion is lossless. That direction is the fix, not the risk. There are two things to watch, though. First, column length semantics: going from a single-byte set to AL32UTF8, a character that was one byte can become up to four, so a VARCHAR2(n BYTE) column may no longer fit its data and the load fails with ORA-12899 or truncates -- define columns with CHAR semantics or size for the expansion. Second, a wrong client NLS_LANG can still corrupt data during the load even with a Unicode target, if a tool misdeclares the character set of the bytes it sends. The data loss described here is specifically migrating the other way, into a narrower set.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the NLS_LANG pass-through trap?
&lt;/h3&gt;

&lt;p&gt;It is character corruption caused not by a narrow database but by a client that misdeclares its character set. NLS_LANG tells the Oracle server what the bytes arriving from a client mean. If a tool actually sends UTF-8 bytes but its NLS_LANG claims the data is US7ASCII (or WE8MSWIN1252), the server believes the declaration and either performs the wrong conversion or none at all, storing mangled bytes -- and this happens even when both the source and target databases are AL32UTF8. It is a common surprise because teams assume that if both databases are Unicode the data is safe, but a single exporter, loader, or client session in the pipeline with a wrong NLS_LANG will corrupt the data in transit. The remedy is to set NLS_LANG to the true character set of the data on every client and tool involved in the migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did my import fail with ORA-12899 after moving to Unicode?
&lt;/h3&gt;

&lt;p&gt;ORA-12899 (value too large for column) on a migration into AL32UTF8 is the byte-expansion trap. In a single-byte character set every character occupies one byte, so a VARCHAR2(10) column holds up to 10 characters. In AL32UTF8 a character can take up to four bytes, and if the column was defined with BYTE length semantics (the default), VARCHAR2(10 BYTE) still means 10 bytes -- so a 10-character name of accented or multibyte characters no longer fits and the insert fails. The fixes are to define the columns with CHAR semantics (VARCHAR2(10 CHAR), which counts characters regardless of byte length), to set NLS_LENGTH_SEMANTICS to CHAR before creating the tables, or to widen the columns to allow for the expansion. It is better to hit ORA-12899 (a loud failure) than silent truncation, so size deliberately rather than relying on defaults.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I recover data after a character-set migration lost it?
&lt;/h3&gt;

&lt;p&gt;Not from the migrated database -- the loss is irreversible. Once José has been stored as Jose, the accent is simply gone; once a name has been stored as ??, the original characters are not recoverable from that value, because the conversion discarded information rather than encoding it differently. Your only recovery is the pre-migration source: the original database, an export taken before cutover, or a backup from before the load. This is exactly why the source must be kept readable and verified at the value level before it is decommissioned, and why the round-trip scan (or DMU/csscan) must be run before the migration. If the source is already gone and was already overwritten, the data cannot be reconstructed.&lt;/p&gt;

&lt;p&gt;Character-set loss belongs to the same family of migrations that "succeed" without being correct: it is the values-level cousin of &lt;a href="https://uptimearchitect.com/blog/oracle-data-pump-done-right/" rel="noopener noreferrer"&gt;counting the rows after a Data Pump job&lt;/a&gt;, where a load reports success but the data isn't whole — here the rows are all present and it's the characters inside them that are missing. It's the detail that turns a clean-looking &lt;a href="https://uptimearchitect.com/blog/oracle-cloud-migration-methods/" rel="noopener noreferrer"&gt;cloud migration&lt;/a&gt; into a month-late incident. Treat "completed successfully" as the start of verification, not the end of it: scan the source for lossy data with DMU or a round-trip query, migrate into a Unicode (AL32UTF8) target so there is nothing to lose, set &lt;code&gt;NLS_LANG&lt;/code&gt; correctly on every client, and prove it the way that ends the argument — with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/charset-migration" rel="noopener noreferrer"&gt;character-set migration lab&lt;/a&gt;, where a US7ASCII target silently damages half the names and a Unicode target keeps every one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-character-set-migration-data-loss/" rel="noopener noreferrer"&gt;uptimearchitect.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>database</category>
      <category>migration</category>
      <category>unicode</category>
    </item>
    <item>
      <title>Your Oracle Traffic Is Readable on the Wire. Here's the Proof, and the Fix.</title>
      <dc:creator>Uptime Architect</dc:creator>
      <pubDate>Sun, 04 Oct 2026 18:26:04 +0000</pubDate>
      <link>https://dev.to/uptimearchitect/your-oracle-traffic-is-readable-on-the-wire-heres-the-proof-and-the-fix-312g</link>
      <guid>https://dev.to/uptimearchitect/your-oracle-traffic-is-readable-on-the-wire-heres-the-proof-and-the-fix-312g</guid>
      <description>&lt;p&gt;The database is patched. The data files are encrypted with TDE. The audit trail is clean. And every query your application runs — along with every row it gets back — crosses the network as readable text. Not because anyone turned encryption off, but because nobody ever turned it on: out of the box, an Oracle client and an Oracle server agree to talk &lt;strong&gt;in plaintext&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is not a theoretical weakness. Anyone who can see the packets — a compromised jump host, a mirrored switch port, a misconfigured cloud network, a curious admin with &lt;code&gt;tcpdump&lt;/code&gt; — can read the SQL, the bind values, and the result sets. This post shows exactly what is on the wire, why the default is plaintext, the four lines of &lt;code&gt;sqlnet.ora&lt;/code&gt; that fix it, how to &lt;em&gt;verify&lt;/em&gt; a session is actually encrypted, and a lab that captures the same session before and after.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually on the wire
&lt;/h2&gt;

&lt;p&gt;Here is what a packet capture of an ordinary session looks like on a default install. The &lt;code&gt;APP&lt;/code&gt; user connects over TCP and runs one query against a table holding a confidential value; &lt;code&gt;tcpdump -A&lt;/code&gt; on port 1521 shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wire&amp;gt; select 'ROWS='||count(*) from vault where secret like 'CANARY-%'
wire&amp;gt; CANARY-7731-CONFIDENTIAL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the &lt;strong&gt;SQL text&lt;/strong&gt; and the &lt;strong&gt;result value&lt;/strong&gt;, lifted straight out of the packets. No exploit, no credentials, no access to the database — just a view of the network path.&lt;/p&gt;

&lt;p&gt;Two things are worth being precise about. First, &lt;strong&gt;the password is not the problem&lt;/strong&gt;: Oracle's logon is a challenge-response, so the password itself does not cross in the clear even here. Everything &lt;em&gt;after&lt;/em&gt; the logon does. Second, this is not an "old version" issue. The capture above is from the current Oracle Database Free release, with nothing misconfigured — it is simply the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the default is plaintext
&lt;/h2&gt;

&lt;p&gt;Oracle's native network encryption is negotiated per connection, and each side has a setting in &lt;code&gt;sqlnet.ora&lt;/code&gt;: &lt;code&gt;SQLNET.ENCRYPTION_SERVER&lt;/code&gt; on the database host and &lt;code&gt;SQLNET.ENCRYPTION_CLIENT&lt;/code&gt; on the client. Each can be one of four values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;REJECTED&lt;/code&gt;&lt;/strong&gt; — never encrypt; refuse a peer that insists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ACCEPTED&lt;/code&gt;&lt;/strong&gt; — encrypt only if the other side asks. &lt;em&gt;This is the default on both sides.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;REQUESTED&lt;/code&gt;&lt;/strong&gt; — ask for encryption, but fall back to plaintext if the other side won't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;REQUIRED&lt;/code&gt;&lt;/strong&gt; — encrypt or refuse the connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trap is in that default. When both ends are &lt;code&gt;ACCEPTED&lt;/code&gt;, each is willing to encrypt but &lt;strong&gt;neither asks&lt;/strong&gt; — so the negotiation succeeds with no encryption at all. Nothing is broken, nothing logs a warning, and the session runs in plaintext forever.&lt;/p&gt;

&lt;p&gt;The integrity side works the same way with &lt;code&gt;SQLNET.CRYPTO_CHECKSUM_SERVER&lt;/code&gt; / &lt;code&gt;_CLIENT&lt;/code&gt;: a per-packet cryptographic checksum that detects tampering and replay in transit. It is a separate switch, and it defaults to off for the same reason.&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%3AeNp9kl1rwjAUhv_KIVfK5tyEeSFDKTUwh9SadsNhx4jtUctqwpK4Xqj_fWmsH-xidzmQ98lzcs6OpDJD0gOyLGSZrrkyEA8TAeDPExJgCWmRozCQSiEwNbkUCfmAVqsPwS4hEaofVKC_C4HmTir-tFDtPg189h7Go0nwGVH2RtkgIYeKGVTBfUI836dhTIfQyHDJt4VpJmQPngX6x9e4_tIDx2rU79cXe3DKNmumVzOFBCnQJStYOK_stLbCoLZCQy4gHHujIKaz2JGj6RhuQMlSg0Ke8UWBlgBmjVDmCm2b18qMTl9HjA4rNruIajQmF6tTg-xvg22ogjS6nE8Q6v53JU3ODYJHo85j1_pEz549OEEhzdqy_7NjZ7sX6sdH8MyC_fO0bHq51Zg54oR5rYdOt3tfx9OCaw0hLHh2KSmspLyqZ1ByJcgtkA2qDc8zuy07YkU2bm_qwZDD4RdLHros" 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%3AeNp9kl1rwjAUhv_KIVfK5tyEeSFDKTUwh9SadsNhx4jtUctqwpK4Xqj_fWmsH-xidzmQ98lzcs6OpDJD0gOyLGSZrrkyEA8TAeDPExJgCWmRozCQSiEwNbkUCfmAVqsPwS4hEaofVKC_C4HmTir-tFDtPg189h7Go0nwGVH2RtkgIYeKGVTBfUI836dhTIfQyHDJt4VpJmQPngX6x9e4_tIDx2rU79cXe3DKNmumVzOFBCnQJStYOK_stLbCoLZCQy4gHHujIKaz2JGj6RhuQMlSg0Ke8UWBlgBmjVDmCm2b18qMTl9HjA4rNruIajQmF6tTg-xvg22ogjS6nE8Q6v53JU3ODYJHo85j1_pEz549OEEhzdqy_7NjZ7sX6sdH8MyC_fO0bHq51Zg54oR5rYdOt3tfx9OCaw0hLHh2KSmspLyqZ1ByJcgtkA2qDc8zuy07YkU2bm_qwZDD4RdLHros" alt="Native network encryption is negotiated per connection from the two sides' settings. With " width="841" height="958"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Native network encryption is negotiated per connection from the two sides' settings. With the default ACCEPTED on both ends, each side is willing but neither asks, so the session runs in plaintext. Setting the server to REQUIRED forces every new session to negotiate AES256 (clients default to ACCEPTED, so no client change is needed). A client that is explicitly REJECTED is refused with ORA-12660 rather than silently falling back to plaintext.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: four lines on the server
&lt;/h2&gt;

&lt;p&gt;Make encryption and integrity &lt;strong&gt;required&lt;/strong&gt; on the database server, and pin strong algorithms. In the server's &lt;code&gt;sqlnet.ora&lt;/code&gt; (normally &lt;code&gt;$ORACLE_HOME/network/admin&lt;/code&gt;, or wherever &lt;code&gt;TNS_ADMIN&lt;/code&gt; points):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SQLNET.ENCRYPTION_SERVER = REQUIRED
SQLNET.ENCRYPTION_TYPES_SERVER = (AES256)
SQLNET.CRYPTO_CHECKSUM_SERVER = REQUIRED
SQLNET.CRYPTO_CHECKSUM_TYPES_SERVER = (SHA256)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why the server, and why &lt;code&gt;REQUIRED&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One change covers every client.&lt;/strong&gt; Clients default to &lt;code&gt;ACCEPTED&lt;/code&gt;, so once the server requires encryption, every client that hasn't explicitly rejected it simply negotiates it. No application change, no client redeploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;REQUIRED&lt;/code&gt;, not &lt;code&gt;REQUESTED&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;REQUESTED&lt;/code&gt; falls back to plaintext if a peer declines — which means a misconfigured client silently stays readable. &lt;code&gt;REQUIRED&lt;/code&gt; makes that client fail loudly with &lt;code&gt;ORA-12660&lt;/code&gt; instead, which is what you want to find in testing, not in an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the algorithm list.&lt;/strong&gt; List only the algorithms you accept (AES256, and AES192/AES128 if you must), so nothing negotiates down to a legacy cipher. Same for the checksum: SHA-2 family only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity is not optional.&lt;/strong&gt; Encryption without the checksum hides the data but doesn't stop tampering or replay. Require both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No restart is needed: the server reads &lt;code&gt;sqlnet.ora&lt;/code&gt; when it starts a new server process for a new connection. That also means &lt;strong&gt;existing sessions keep whatever they negotiated&lt;/strong&gt; — recycle connection pools after the change, or they will stay in plaintext until they reconnect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it, don't assume it
&lt;/h2&gt;

&lt;p&gt;A config file is a claim. The proof is what each session actually negotiated, which Oracle exposes in &lt;code&gt;V$SESSION_CONNECT_INFO&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;network_service_banner&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;session_connect_info&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="o"&gt;=&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;'SID'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On an encrypted session you will see lines naming the algorithm in use — &lt;code&gt;AES256 Encryption service adapter…&lt;/code&gt; and &lt;code&gt;SHA256 Crypto-checksumming service adapter…&lt;/code&gt;. On a plaintext session you will only see the generic "Encryption service for Linux" / "Crypto-checksumming service" lines with no algorithm adapter. Run the same check across all sessions (drop the &lt;code&gt;WHERE&lt;/code&gt;, join to &lt;code&gt;V$SESSION&lt;/code&gt; for usernames and programs) to find any connection still in the clear.&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/sqlnet-encryption" rel="noopener noreferrer"&gt;SQL*Net encryption lab&lt;/a&gt; stands up Oracle Database Free and a throwaway &lt;code&gt;tcpdump&lt;/code&gt; sidecar that shares the database's network namespace. The &lt;code&gt;APP&lt;/code&gt; user reads a confidential row over TCP: on the default config the session negotiates &lt;strong&gt;no encryption&lt;/strong&gt; and the capture contains both the SQL text and the secret value. It then adds the four &lt;code&gt;sqlnet.ora&lt;/code&gt; lines, runs the &lt;strong&gt;same&lt;/strong&gt; session under the &lt;strong&gt;same&lt;/strong&gt; capture, and asserts the session negotiated &lt;code&gt;AES256&lt;/code&gt; / &lt;code&gt;SHA256&lt;/code&gt; and that neither the SQL nor the secret appears anywhere in the packets. If the plaintext doesn't reproduce, or the fix doesn't hide it, the run fails. Proven on every CI push.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the lab, the before-and-after is unambiguous:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;default : session encryption: NONE    integrity: NONE    SQL seen 2x, secret seen 1x
fixed   : session encryption: AES256  integrity: SHA256  SQL seen 0x, secret seen 0x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Native encryption or TLS?
&lt;/h2&gt;

&lt;p&gt;Oracle offers two ways to encrypt the connection, and both are included in every edition at no extra cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native network encryption&lt;/strong&gt; (what this post configures) — a few lines of &lt;code&gt;sqlnet.ora&lt;/code&gt;, no certificates, and it works with existing connect strings on port 1521. The fastest way to get every session off plaintext.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS (TCPS)&lt;/strong&gt; — certificate-based, usually on a separate port. Its key advantage is &lt;strong&gt;server authentication&lt;/strong&gt;: the client verifies it is talking to the real database, which protects against a rogue or spoofed listener in the middle. Native encryption has no such check. TLS is also what many compliance frameworks name explicitly, and it encrypts the connect packet that native encryption leaves readable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical path: turn on native encryption with &lt;code&gt;REQUIRED&lt;/code&gt; now — it closes the plaintext gap today with almost no risk — and plan TLS where you need server authentication or an auditor asks for it by name.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Assuming TDE covers it.&lt;/strong&gt; TDE encrypts data &lt;em&gt;at rest&lt;/em&gt; — data files and backups. The moment a row is read and sent to a client, TDE is out of the picture. In-transit protection is a separate control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming the network is private.&lt;/strong&gt; "It's all inside the VPC / the data center" is exactly the assumption an attacker with one foothold relies on. Internal traffic is where lateral movement happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setting &lt;code&gt;REQUESTED&lt;/code&gt; and calling it done.&lt;/strong&gt; It looks like encryption is on, but any peer that declines gets a plaintext session with no error. Use &lt;code&gt;REQUIRED&lt;/code&gt; and let incompatible clients fail where you can see them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypting without integrity.&lt;/strong&gt; Without &lt;code&gt;CRYPTO_CHECKSUM&lt;/code&gt;, traffic is hidden but can still be modified or replayed in transit. Require both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never verifying.&lt;/strong&gt; A &lt;code&gt;sqlnet.ora&lt;/code&gt; in the wrong directory, an overriding &lt;code&gt;TNS_ADMIN&lt;/code&gt;, or a pool that never reconnected all leave sessions in plaintext while the config looks correct. Check &lt;code&gt;V$SESSION_CONNECT_INFO&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting everything to be hidden.&lt;/strong&gt; The initial connect packet — service name, client program, OS user — is sent before negotiation and stays readable with native encryption. The database username, SQL and data do not. If the connect descriptor itself is sensitive, that's an argument for TLS.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Is Oracle database network traffic encrypted by default?
&lt;/h3&gt;

&lt;p&gt;No. By default both the Oracle client and the database server have their native network encryption setting at ACCEPTED, which means each side is willing to encrypt but neither one requests it, so the connection is negotiated without encryption. SQL statements, bind values and result sets then cross the network as readable bytes that anyone who can capture the packets can read. The logon password is protected by Oracle's challenge-response authentication, but everything after logon is in plaintext until you configure encryption, either with native network encryption in sqlnet.ora or with TLS (TCPS).&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I enable Oracle native network encryption?
&lt;/h3&gt;

&lt;p&gt;Add four lines to the database server's sqlnet.ora (normally in $ORACLE_HOME/network/admin or the directory TNS_ADMIN points to): SQLNET.ENCRYPTION_SERVER = REQUIRED, SQLNET.ENCRYPTION_TYPES_SERVER = (AES256), SQLNET.CRYPTO_CHECKSUM_SERVER = REQUIRED, and SQLNET.CRYPTO_CHECKSUM_TYPES_SERVER = (SHA256). Because clients default to ACCEPTED, every new connection then negotiates AES256 encryption and SHA-256 integrity without any client change. No database restart is needed, but existing sessions keep what they already negotiated, so recycle application connection pools afterwards and verify the result in V$SESSION_CONNECT_INFO.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between ACCEPTED, REQUESTED and REQUIRED?
&lt;/h3&gt;

&lt;p&gt;They control how each side negotiates encryption. REJECTED never encrypts and refuses a peer that requires it. ACCEPTED, the default, encrypts only if the other side asks. REQUESTED asks for encryption but falls back to plaintext if the other side declines. REQUIRED insists on encryption and refuses the connection otherwise. Encryption is used when at least one side requests or requires it and the other does not reject it; if one side is REQUIRED and the other REJECTED, the connection fails with ORA-12660. Using REQUIRED on the server is the safe choice because it turns a silent plaintext fallback into a visible error.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can I check whether an Oracle session is encrypted?
&lt;/h3&gt;

&lt;p&gt;Query V$SESSION_CONNECT_INFO for the session, for example SELECT network_service_banner FROM v$session_connect_info WHERE sid = SYS_CONTEXT('USERENV','SID'). An encrypted session shows a line naming the algorithm adapter in use, such as 'AES256 Encryption service adapter', and an integrity-protected session shows a line such as 'SHA256 Crypto-checksumming service adapter'. A plaintext session only shows the generic encryption and crypto-checksumming service lines with no algorithm adapter. To audit the whole instance, query it for all sessions joined to V$SESSION to see which users and programs are still connecting without encryption.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does native network encryption need a separate license?
&lt;/h3&gt;

&lt;p&gt;No. Oracle native network encryption and data integrity, as well as TLS (TCPS) for database connections, were once part of the separately licensed Advanced Security option, but Oracle made network encryption available in all editions of the database years ago. Transparent Data Encryption and Data Redaction remain part of Advanced Security, but encrypting traffic between clients and the database does not require it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use native network encryption or TLS?
&lt;/h3&gt;

&lt;p&gt;Both encrypt the traffic. Native network encryption is configured with a few sqlnet.ora parameters, needs no certificates, and works with existing connect strings, which makes it the fastest way to get every session off plaintext. TLS (TCPS) uses certificates and adds server authentication, so the client verifies it is connected to the genuine database and is protected against a spoofed listener or man-in-the-middle; it also encrypts the initial connect packet and is the control many compliance frameworks name explicitly. A common approach is to enable native encryption with REQUIRED immediately, and adopt TLS where server authentication or a specific compliance requirement calls for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Transparent Data Encryption protect data in transit?
&lt;/h3&gt;

&lt;p&gt;No. Transparent Data Encryption (TDE) encrypts data at rest, in data files, redo, and backups, so that stolen files or media are unreadable. When a session queries the data, the database decrypts it and sends the result to the client, and at that point TDE no longer applies. Protecting data as it travels between the client and the database requires network encryption, either native network encryption or TLS. A complete setup uses both: TDE for data at rest and network encryption for data in transit.&lt;/p&gt;

&lt;p&gt;Network encryption is the in-transit half of a picture the rest of the security series builds from other angles: &lt;a href="https://uptimearchitect.com/blog/oracle-database-hardening-checklist/" rel="noopener noreferrer"&gt;the hardening checklist&lt;/a&gt; decides who can connect and how, &lt;a href="https://uptimearchitect.com/blog/oracle-transparent-data-encryption-tde/" rel="noopener noreferrer"&gt;TDE&lt;/a&gt; protects the data files and backups at rest, &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, and &lt;a href="https://uptimearchitect.com/blog/oracle-unified-auditing/" rel="noopener noreferrer"&gt;unified auditing&lt;/a&gt; records who asked. None of them help once a result set is crossing the network in readable bytes. Set the server to &lt;code&gt;REQUIRED&lt;/code&gt;, pin AES256 and SHA-256, verify every session in &lt;code&gt;V$SESSION_CONNECT_INFO&lt;/code&gt;, and then prove it the way that ends the argument: with the &lt;a href="https://github.com/pyaroslav/oracle-labs/tree/main/sqlnet-encryption" rel="noopener noreferrer"&gt;SQL*Net encryption lab&lt;/a&gt;, where the same query is readable in a packet capture before the fix and unreadable after.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://uptimearchitect.com/blog/oracle-sqlnet-network-encryption/" 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>encryption</category>
    </item>
  </channel>
</rss>
