<?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: Peter Bednarčík</title>
    <description>The latest articles on DEV Community by Peter Bednarčík (@pbednarcik).</description>
    <link>https://dev.to/pbednarcik</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%2F2104126%2Fca9efc26-c161-4228-b23c-54abbdf7cb8d.png</url>
      <title>DEV Community: Peter Bednarčík</title>
      <link>https://dev.to/pbednarcik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pbednarcik"/>
    <language>en</language>
    <item>
      <title>An EF Core race condition that only showed up after the switch to Azure</title>
      <dc:creator>Peter Bednarčík</dc:creator>
      <pubDate>Thu, 15 Oct 2026 09:12:23 +0000</pubDate>
      <link>https://dev.to/pbednarcik/an-ef-core-race-condition-that-only-showed-up-after-the-switch-to-azure-25em</link>
      <guid>https://dev.to/pbednarcik/an-ef-core-race-condition-that-only-showed-up-after-the-switch-to-azure-25em</guid>
      <description>&lt;p&gt;A few months ago, I faced one of those head-scratching problems. Something happened in production that shouldn't happen: a record got closed that nobody closed. Unfortunately, it happened on a critical path in the system, so we needed a fast fix.&lt;/p&gt;

&lt;p&gt;Nothing in the error logs. No leads in other log levels either. No recent code change that could explain it. It got me thinking about a recent issue we had: code that had worked fine for years broke after we switched to Azure. My first suspect was a race condition between overlapping Hangfire jobs, but I disproved it: we turned off the second Hangfire server, and the issue persisted. I did notice it happened mostly during rush hours in production.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Note:&lt;/strong&gt; The real system isn't a warehouse. I changed the domain and names, but the mechanics are exactly the same. The full reproduction is on &lt;a href="https://github.com/pbednarcik/blog-examples/tree/main/ef-dirty-read-race" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;For this article, I moved the problem to a warehouse that picks orders in batches. One pick list, one walk through the warehouse, can serve several orders, so pick lists and orders are linked through a link table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PickList&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&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;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PickListOrder&lt;/span&gt;&lt;span class="p"&gt;&amp;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;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PickListOrder&lt;/span&gt; &lt;span class="c1"&gt;// link row&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;PickListId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;OrderId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;When an order ships, the open pick lists that served only that order get closed. A pick list shared with another order stays open until that order ships too.&lt;/p&gt;

&lt;p&gt;In our case, shipping order A closed order B's brand-new pick list.&lt;/p&gt;
&lt;h2&gt;
  
  
  Logging
&lt;/h2&gt;

&lt;p&gt;For this project, a new deployment isn't as straightforward as pushing a button and waiting a few minutes, so we couldn't just try things in production. We needed proof before changing any logic.&lt;/p&gt;

&lt;p&gt;Logging was the first thing that could help without breaking anything else. We added a log line before and after every place in the code that closes a pick list, including the pick list, the order it was closed for, and both statuses.&lt;/p&gt;

&lt;p&gt;When it happened again, the logs narrowed it down to one place: the code that ships orders closed the pick list. So that was where I started looking.&lt;/p&gt;
&lt;h2&gt;
  
  
  The query
&lt;/h2&gt;

&lt;p&gt;When an order ships, this query decides which pick lists get closed:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pickLists&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PickLists&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"Open"&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;All&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;=&amp;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;OrderId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pickList&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pickLists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pickList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Closed"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;When I was reading the code, this line didn't catch my attention. It reads exactly like the rule: close the open pick lists whose orders are all this order. A pick list shared with order C doesn't match, which is what we want.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;All()&lt;/code&gt; on an empty collection returns &lt;code&gt;true&lt;/code&gt;. It's even in the &lt;a href="https://learn.microsoft.com/dotnet/api/system.linq.enumerable.all" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;: "…or if the sequence is empty". With no links, nothing breaks the condition, so a pick list without any orders matches every order.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Search your code for &lt;code&gt;.All(&lt;/code&gt; on navigation collections. Each one is &lt;code&gt;true&lt;/code&gt; for an entity whose collection is still empty, so each is worth a second look.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Can a pick list exist without its orders? Not once it's saved. The pick list and its links are saved together in one &lt;code&gt;SaveChanges()&lt;/code&gt;, which EF runs in one transaction. So nobody should ever see one without the other. Unless they read data that isn't committed yet.&lt;/p&gt;
&lt;h2&gt;
  
  
  The transaction
&lt;/h2&gt;

&lt;p&gt;Deep in our persistence layer there is a base class, written years ago, that starts every transaction with the following code:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IsolationLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadUncommitted&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;READ UNCOMMITTED&lt;/code&gt; means dirty reads. The transaction sees rows that other sessions have written but not committed yet. The shipping code ran inside one of these transactions.&lt;/p&gt;

&lt;p&gt;EF saves a new pick list with its link in two commands, because the link needs the pick list's generated id:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;INSERT&lt;/code&gt; the pick list and read back its id.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;INSERT&lt;/code&gt; the link row with that id.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same transaction, but two round trips to the database. Between them, the pick list exists with zero orders. A dirty read at that moment sees exactly that, and &lt;code&gt;All()&lt;/code&gt; matches it. Either one alone would have been fine.&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%2Fj37g0fmrh98592hcksfa.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%2Fj37g0fmrh98592hcksfa.png" alt="Timeline: session 1 inserts pick list 3 for order B and, before inserting its link, session 2 shipping order A reads it with 0 orders, All() is true, and it closes pick lists 1 and 3" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It also explained why it started after the move. We still ran SQL Server with the same database settings, just on an Azure VM instead of a server in a hosted data center. The gap isn't only the network round trip; it also includes the work on the web server between the two commands, and that needs CPU time. I never measured it, but round trips in Azure are likely longer than before, and I suspect thread scheduling plays a part too: the earlier bug I mentioned at the start came down to how threads were scheduled there. During rush hours, more requests compete for the CPU and the network, so the gap gets longer and more requests land in it.&lt;/p&gt;

&lt;p&gt;The original intent of that base class was most likely reads that never block: &lt;code&gt;READ UNCOMMITTED&lt;/code&gt;, or &lt;code&gt;NOLOCK&lt;/code&gt; on every query, was a common way to avoid blocking in older SQL Server codebases. SQL Server 2005 added a safer way, read committed snapshot isolation (RCSI), but turning it on changes every read in the database, so this base class never changed.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  How RCSI works
  &lt;br&gt;
When a row is updated or deleted, SQL Server first copies the old version into a version store (in &lt;code&gt;tempdb&lt;/code&gt;, or in the database itself with accelerated database recovery). Readers take no locks on the rows. Each statement reads the data as it was committed when the statement started, using the old versions where needed. The cost is extra storage and a bit more work on every write.&lt;br&gt;


&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Reproducing it
&lt;/h2&gt;

&lt;p&gt;I couldn't reproduce it locally. I have a fairly fast machine, and the app on it had exactly one user: me. The gap was tiny, and the other request would have to land exactly inside it. Sometimes strong hardware is a disadvantage :D.&lt;/p&gt;

&lt;p&gt;So I decided to make the gap bigger instead. EF Core command interceptors run around every command EF sends, so one of them can wait right after the pick list &lt;code&gt;INSERT&lt;/code&gt;, while the transaction is still open:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DelayAfterCommandInterceptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;commandTextFragment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DbCommandInterceptor&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;ValueTask&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DbDataReader&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ReaderExecutedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;DbCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CommandExecutedEventData&lt;/span&gt; &lt;span class="n"&gt;eventData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DbDataReader&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CommandText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;commandTextFragment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is a shortened version of the one in the repository, which also logs every command it sees. It only overrides the async method. If your code calls &lt;code&gt;SaveChanges()&lt;/code&gt;, override &lt;code&gt;ReaderExecuted&lt;/code&gt; the same way.&lt;/p&gt;

&lt;p&gt;I registered it in debug builds only, for the insert I suspected:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if DEBUG
&lt;/span&gt;&lt;span class="n"&gt;optionsBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddInterceptors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DelayAfterCommandInterceptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"INSERT INTO [PickLists]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="cp"&gt;#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That turns the tiny gap into a two-second one, and then it reproduces every time. This is the data in the repro when the race starts:&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%2Fxxn8dq8nwzalmen7zbej.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%2Fxxn8dq8nwzalmen7zbej.png" alt="Pick list 1 serves only order A, pick list 2 is shared by orders A and C, and pick list 3 is being saved for order B, with its link not saved yet. Order A ships now." width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Order B gets pick list 3 while order A ships:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ 0.04s] create ran: INSERT INTO [PickLists] ([Status])
[ 0.04s] create pausing 2.0s, transaction still open
[ 0.05s] ship   shipping order A, ReadUncommitted transaction, closing pick lists that served only A (All())
[ 0.19s] ship   found pick list(s) 1, 3
[ 2.05s] create ran: INSERT INTO [PickListOrders] ([OrderId], [PickListId])
[ 2.05s] create committed
[ 2.06s] check  order B's pick list 3 is Closed   &amp;lt;-- CLOSED BY SHIPPING ORDER A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Pick list 2 stays open, as it should. Pick list 3, caught between its two inserts, gets closed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;p&gt;With a repro that fails every time, I could test the fixes instead of guessing. Measured on SQL Server 2022 with EF Core 10:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Isolation&lt;/th&gt;
&lt;th&gt;RCSI&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;All()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read uncommitted&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;❌ closes B's pick list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Any() &amp;amp;&amp;amp; All()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read uncommitted&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;✅ correct (the quick fix)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;All()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read committed&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;✅ correct, but waits 2.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;All()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read committed&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;✅ correct, no waiting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;All()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read uncommitted&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;❌ closes B's pick list&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; var pickLists = db.PickLists
&lt;span class="gd"&gt;-    .Where(p =&amp;gt; p.Status == "Open" &amp;amp;&amp;amp; p.Orders.All(o =&amp;gt; o.OrderId == orderId))
&lt;/span&gt;&lt;span class="gi"&gt;+    .Where(p =&amp;gt; p.Status == "Open" &amp;amp;&amp;amp; p.Orders.Any() &amp;amp;&amp;amp; p.Orders.All(o =&amp;gt; o.OrderId == orderId))
&lt;/span&gt;     .ToList();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The second row is what we shipped: an empty collection can't match any more. It was small and safe, but the dirty read is still there and still sees half-saved data.&lt;/p&gt;

&lt;p&gt;Without the dirty read, the bug is gone. Plain &lt;code&gt;READ COMMITTED&lt;/code&gt; waits for the other transaction to commit, two seconds here; with RCSI turned on it doesn't wait at all. RCSI alone doesn't help, though: an explicit &lt;code&gt;READ UNCOMMITTED&lt;/code&gt;, or a &lt;code&gt;NOLOCK&lt;/code&gt; hint, overrides it.&lt;/p&gt;

&lt;p&gt;Changing the isolation level in that base class changes every transaction in the application, so it's a separate change with a possibly huge impact on the legacy parts. Code that has read without waiting for years would suddenly wait on writers, or with RCSI read different data than before, and it's hard to tell which parts depend on the old behavior.&lt;/p&gt;
&lt;h2&gt;
  
  
  EF turns RCSI on
&lt;/h2&gt;

&lt;p&gt;While building the repro, my "RCSI off" scenarios didn't block when they should have. It turned out that when EF Core creates a database with &lt;code&gt;EnsureCreated()&lt;/code&gt; or migrations, it also runs &lt;code&gt;ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON&lt;/code&gt; (except on Azure SQL Database, where it's on by default anyway). On SQL Server, a database created with a plain &lt;code&gt;CREATE DATABASE&lt;/code&gt; has it off.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Watch out:&lt;/strong&gt; a local database created by EF can behave differently from production in anything that involves locks. If you're chasing a locking bug, check both sides first:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_read_committed_snapshot_on&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;databases&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Try it
&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/pbednarcik" rel="noopener noreferrer"&gt;
        pbednarcik
      &lt;/a&gt; / &lt;a href="https://github.com/pbednarcik/blog-examples" rel="noopener noreferrer"&gt;
        blog-examples
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Companion code for blog posts: small examples and runnable repros.
    &lt;/h3&gt;
  &lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;It needs the .NET 10 SDK and a SQL Server where you can create a database. By default, it connects to the local default instance with Windows authentication; set &lt;code&gt;REPRO_CONNECTION&lt;/code&gt; to a connection string to use another server. Every scenario drops and recreates a database called &lt;code&gt;DirtyReadRepro&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/pbednarcik/blog-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;blog-examples/ef-dirty-read-race
dotnet run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs all five scenarios and prints a summary of the results behind the Fix table. To run just one, for example, the bug itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--&lt;/span&gt; all ru rcsi-off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Have you hit a bug that only showed up after moving to the cloud? What was it, and how did you find it?&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I used AI to help edit this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>efcore</category>
      <category>sqlserver</category>
      <category>csharp</category>
    </item>
    <item>
      <title>FlexReadDir: up to 3.8x faster directory listing in Go</title>
      <dc:creator>Peter Bednarčík</dc:creator>
      <pubDate>Sun, 11 Oct 2026 21:41:16 +0000</pubDate>
      <link>https://dev.to/pbednarcik/flexreaddir-up-to-38x-faster-directory-listing-in-go-3aoh</link>
      <guid>https://dev.to/pbednarcik/flexreaddir-up-to-38x-faster-directory-listing-in-go-3aoh</guid>
      <description>&lt;p&gt;Almost every tool that looks at files asks the same question: &lt;em&gt;What's in this directory, how big is each file, and when did it last change?&lt;/em&gt; Backup tools ask it, build caches ask it, sync clients, indexers, and file watchers ask it, thousands of times a minute.&lt;/p&gt;

&lt;p&gt;In Go, the usual answer looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c"&gt;// on macOS and Linux: one more system call per file&lt;/span&gt;
    &lt;span class="n"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModTime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On macOS and Linux, that's one call to list the directory, then one &lt;code&gt;lstat&lt;/code&gt; for every file. On Windows, the listing already carries sizes and times, but the standard library still does a lot of work per entry. For 1,000 files, it makes 2,000 to 5,000 allocations, depending on the platform.&lt;/p&gt;

&lt;p&gt;macOS can return names and metadata in one call, and Linux has cheaper ways to stat a file than using a full path. os.ReadDir with Info uses neither.&lt;/p&gt;

&lt;p&gt;So I decided to write a small Go library that does: &lt;a href="https://github.com/pbednarcik/flexreaddir" rel="noopener noreferrer"&gt;FlexReadDir&lt;/a&gt;. It lists a directory and gives you every file's size and modification time in one pass.&lt;/p&gt;

&lt;p&gt;It's part of something bigger I'm working on, but I wanted to show this piece on its own. I also like optimizing software and getting as much performance as I can out of tools when it makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/pbednarcik/flexreaddir/frd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;frd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsRegular&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%-24s %8d bytes  %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2006-01-02 15:04"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ReadDir&lt;/code&gt; returns entries sorted by name, exactly like &lt;code&gt;os.ReadDir&lt;/code&gt;. Each &lt;code&gt;Entry&lt;/code&gt; is a plain struct. Size, times, and file id are filled for regular files only; for directories, links, and other types they are zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Entry&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;            &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Type&lt;/span&gt;            &lt;span class="n"&gt;fs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FileMode&lt;/span&gt;
    &lt;span class="n"&gt;Size&lt;/span&gt;            &lt;span class="kt"&gt;int64&lt;/span&gt;
    &lt;span class="n"&gt;ModTimeNanos&lt;/span&gt;    &lt;span class="kt"&gt;int64&lt;/span&gt;
    &lt;span class="n"&gt;ChangeTimeNanos&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;
    &lt;span class="n"&gt;FileID&lt;/span&gt;          &lt;span class="kt"&gt;uint64&lt;/span&gt; &lt;span class="c"&gt;// inode / file id; 0 if unknown&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;Entry&lt;/code&gt; is comparable, comparing a file's recorded metadata with the last scan is a single &lt;code&gt;==&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsRegular&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"changed:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With both the change time and the file id in there, a scanner can even tell a file that was rewritten in place from one that was replaced by another file with the same name, where the filesystem gives a persistent file id.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmark
&lt;/h2&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%2F93fs7q0q1xv1pa4quirc.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%2F93fs7q0q1xv1pa4quirc.png" alt=" " width="800" height="321"&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;Platform&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;ReadDir&lt;/code&gt; + &lt;code&gt;Info&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Readdir&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;FlexReadDir&lt;/th&gt;
&lt;th&gt;Faster&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;macOS&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1,382 µs&lt;/td&gt;
&lt;td&gt;947 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;362 µs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.8×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux (VM)&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;603 µs&lt;/td&gt;
&lt;td&gt;465 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;376 µs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.6×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;164 µs&lt;/td&gt;
&lt;td&gt;161 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;131 µs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.3×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;FlexReadDir here is &lt;code&gt;AppendDir&lt;/code&gt;, unsorted and reusing its result slice, as a walker would use it. "Faster" is against &lt;code&gt;os.File.ReadDir&lt;/code&gt; with &lt;code&gt;Info&lt;/code&gt; for each entry; &lt;code&gt;os.File.Readdir&lt;/code&gt; is the older API that returns a &lt;code&gt;FileInfo&lt;/code&gt; per entry. Every row is the median of ten interleaved runs on a warm file cache, so it shows warm-cache speed, not disk speed. The Mac is an M5 Max on APFS, the Windows box a Ryzen 9 9950X on NTFS, and Linux runs in a VM on that same Mac, so treat its row as a direction, not a size.&lt;/p&gt;

&lt;p&gt;The gain is different on each platform, because the standard library does more extra work on some systems than on others. The macOS result surprised me the most. &lt;code&gt;getattrlistbulk&lt;/code&gt; is a real bulk call, and it shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;On macOS, it uses &lt;code&gt;getattrlistbulk&lt;/code&gt;. You tell the kernel which attributes you want (name, type, size, times, file ID), and it returns records for many files at once. &lt;code&gt;os.ReadDir&lt;/code&gt; + &lt;code&gt;Info&lt;/code&gt; calls &lt;code&gt;lstat&lt;/code&gt; for every file, so most of the 3.8× comes from removing those calls.&lt;/p&gt;

&lt;p&gt;Linux has no bulk stat, so FlexReadDir still calls &lt;code&gt;statx&lt;/code&gt; once per regular file. The savings come from everything around it. The stat is relative to the open directory instead of a full path; it asks only for the fields it needs, and it reads the &lt;code&gt;getdents64&lt;/code&gt; buffer directly without allocating per entry.&lt;/p&gt;

&lt;p&gt;I plan to run it in Docker, so I made sure it works in containers. Some seccomp profiles block &lt;code&gt;statx&lt;/code&gt; with &lt;code&gt;EPERM&lt;/code&gt;. In that case, FlexReadDir falls back to &lt;code&gt;fstatat&lt;/code&gt;, and there is a test with a real seccomp filter for it.&lt;/p&gt;

&lt;p&gt;Windows already returns sizes and times with the listing (&lt;code&gt;FILE_ID_EXTD_DIR_INFO&lt;/code&gt;), so there is little to gain. The savings come from less work per entry in user space and faster file-name decoding.&lt;/p&gt;

&lt;p&gt;No cgo on any platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  SIMD on Windows
&lt;/h2&gt;

&lt;p&gt;I wrote my first small SIMD programs at university. One of them, for a course assignment, calculated a matrix determinant with AVX, and I compared it with a plain C version, so I saw how fast SIMD can be.&lt;/p&gt;

&lt;p&gt;Windows returns file names in UTF-16, and Go strings are UTF-8, so every name must be converted. Most names are plain ASCII, which fits SIMD well. It checks 16 or 32 characters at once and, if they are all ASCII, packs them in one step.&lt;/p&gt;

&lt;p&gt;The decoder has three versions: AVX2, SSE2, and plain Go. The right one is picked at runtime, and &lt;code&gt;-tags purego&lt;/code&gt; forces plain Go. On long names, like in WinSxS, where names average 94 characters, AVX2 decodes about 4× faster than plain Go, and listing that directory is 17 to 20% faster. All three are tested exhaustively and fuzzed against each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Profiling
&lt;/h2&gt;

&lt;p&gt;This stage probably took the most time. Constant reruns, long Google searches, documentation checks, and a lot of waiting on the rebuild-and-retest loop. But that's the price of optimizing.&lt;/p&gt;

&lt;p&gt;After the obvious wins, I wanted to know what was left, so I profiled listings down into the kernel. On Windows, I used the Windows Performance Recorder with Microsoft's public symbols.&lt;/p&gt;

&lt;p&gt;In my profiles, FlexReadDir's own code was about 4% of the time on macOS, 5% on Linux (a tree walk in Docker), and under 13% on Windows. The rest is mostly the kernel.&lt;/p&gt;

&lt;p&gt;Two other things stood out: how much work NTFS does on its own, and how quick Linux is with file name lookups. The Linux part didn't really surprise me. I remember Linus Torvalds saying in a talk that he spent a lot of time keeping cache misses in file name lookups as low as possible.&lt;/p&gt;

&lt;p&gt;On a warm cache, 40 to 49% of a Windows listing is NTFS reading ahead in the directory index, and I couldn't do anything about it. Or I just haven't figured it out yet. Access hints when opening the directory didn't change anything. A 16-times-bigger buffer (1 MiB instead of 64 KiB) wasn't faster either, and on the system drive (C:) it was 4.8% slower. In the end, I left it to Windows. At some point, you have to call it good enough, and I'd reached it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Allocations
&lt;/h2&gt;

&lt;p&gt;I come from the C# world, but I've always been interested in memory and being close to the metal.&lt;/p&gt;

&lt;p&gt;Listing 1,000 files takes 4 or 5 allocations when you reuse the result slice. The names from one read share one allocation, and a &lt;code&gt;Lister&lt;/code&gt; keeps its 64 KiB buffer, so when you list many directories you can reuse both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="n"&gt;frd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lister&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;frd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entry&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;dirs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppendDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// use entries before the next iteration overwrites them&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because names share memory, keeping a &lt;code&gt;Name&lt;/code&gt; for a long time keeps the others alive too. Use &lt;code&gt;strings.Clone&lt;/code&gt; if that matters.&lt;/p&gt;

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

&lt;p&gt;It lists one directory and doesn't walk trees. If you need a walk, an example on pkg.go.dev builds one on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Help wanted
&lt;/h2&gt;

&lt;p&gt;I don't have Linux on real hardware, an Intel CPU, or NFS and SMB shares to test on. If you have any of them, clone the repository and run this. It takes about a minute:&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="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;10&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; ./frd &lt;span class="nt"&gt;-run&lt;/span&gt; &lt;span class="s1"&gt;'^$'&lt;/span&gt; &lt;span class="nt"&gt;-bench&lt;/span&gt; &lt;span class="s1"&gt;'AppendDir'&lt;/span&gt; &lt;span class="nt"&gt;-benchmem&lt;/span&gt; &lt;span class="nt"&gt;-count&lt;/span&gt; 1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows, in PowerShell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ForEach-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;go&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;/frd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'^$'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-bench&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'AppendDir'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-benchmem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The benchmark creates its files in the temp directory, so to test an NFS or SMB share, point &lt;code&gt;TMPDIR&lt;/code&gt; (&lt;code&gt;TMP&lt;/code&gt; on Windows) at a folder on the share first. Open an issue with the output and a line about the machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/pbednarcik/flexreaddir" rel="noopener noreferrer"&gt;github.com/pbednarcik/flexreaddir&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs and examples: &lt;a href="https://pkg.go.dev/github.com/pbednarcik/flexreaddir/frd" rel="noopener noreferrer"&gt;pkg.go.dev/github.com/pbednarcik/flexreaddir/frd&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It requires Go 1.27 or later, runs on macOS, Linux, and Windows, and is MIT-licensed.&lt;/p&gt;

&lt;p&gt;Go was an experiment for me, a way to try something new. It has a garbage collector like C#, so it felt familiar. In the end, it was a great way to learn Go, and I'm sticking with it, at least for now.&lt;/p&gt;

&lt;p&gt;Let me know what you think, especially if you write Go. The language is still new to me, and I tried really hard not to write C# in Go. Hopefully I managed :D If something in the API or the code looks odd, tell me.&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I used AI to help edit this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>performance</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
