When you add approval stages to increase stability, the last thing you expect is instability. That’s the opposite of what you wanted. Yet that’s what happens when organizations respond to incidents by increasing the weight of change approval processes.
That means there’s more art to change approvals than most people realize, and it’s a threat to an organization’s ability to deploy and operate software if they don’t have change finesse.
Watch the episode
You can watch the episode below, or read on to find some of the key discussion points.
Change approvals don’t arrive without reason. If you have a heavyweight change approval process and frequent or extended change freezes, the chances are that they were introduced after a major incident. If you break financial software around tax year-end, banning deployments for a month before and a month after is, in theory, a reasonable resolution.
Almost every industry has a cadence it wants to protect from instability. Retail has seasonal sales events, the music industry has superstar ticket launches, and finance has a peak as the end of the tax year approaches. The goal is to make sure you can operate your business during these times.
With that goal in mind, we have to bust the myth of change approvals as the mechanism to achieve it. Attempting to protect these key moments with change freezes or cumbersome approval processes has one result: increased instability.
Heavyweight change approvals
Heavyweight change approvals make things less stable by delaying work and causing batches of unreleased changes to accumulate. Meanwhile, developers are starting new work and are losing the immediate familiarity with the oldest changes as they press ahead. One of the ways approvals gain weight is through approval chains, which we looked at in depth in the Compliance through Continuous Delivery report.
Large batches also come with admin that can introduce further problems. Testing becomes more difficult, the likelihood of merge issues increases, and pinpointing the source of a problem is far harder.
This is why the DORA research placed streamlined change approvals in their core model for software delivery. Centralized change approval boards don’t work, and process is never the solution to your stability problems.
Streamlining
There are some easy ways to streamline change approvals. Most of these don’t look like traditional change management, which is good because we know that doesn’t work.
The first way to trim the process is to automate your verification stages. At every level of review, tasks can be automated, whether it’s automatically linting and formatting code (instead of debating it), running automated builds and tests, or validating your SBOM is free from insecure dependencies.
Where you need a human review, use a peer-review process for individual changes, enforced on commit, with humans brought in only after the automated validation has passed. If you have advanced change approval needs, categorizing changes by risk lets you apply your people to the changes that most need their perspective.
You won’t achieve all of this in one day. It’s part of your continuous improvement process. You may improve your chances of stripping the bureaucracy if you follow the capability culture cycle pattern.
Small batches, again
If you follow industry experts or the research, you’ll notice that small batches keep cropping up as the answer to many kinds of dysfunction. That’s not a coincidence. Large batches cause far-reaching problems that build superlinearly as more changes collect unreleased.
Anything that causes batch size to increase, including change approvals, must be subject to fierce improvement.
Happy deployments!
Continuous Delivery Office Hours is a series of conversations about software delivery, with Tony Kelly, Bob Walker, and Steve Fenton.
The talks are set for Pure Virtual C++ 2026, our free, one-day virtual conference for the whole C++ community. We’re excited to reveal the featured session lineup and the speakers bringing it to you.
Mark your calendar: Tuesday, July 21, 2026, starting at 16:00 UTC (9:00 AM in Seattle, 6:00 PM in Brussels, 11:00 PM in Bangkok). The conference is completely free and streamed online, so you can join from anywhere in the world.
Featured Talks
Here are the featured sessions you’ll see at Pure Virtual C++ 2026:
C++ Semantic Awareness in the CLI: From Project Load to Code Change with Sinem Akinci
C++/WinRT: Build Faster and Smaller with C++20 Modules with Ryan Shepherd
Mind the Gap: C++/Rust Interop with Victor Ciura
From Completions to Agents: AI-Driven C++ in Visual Studio with Augustin Popa
Cut Your Build Times Without Becoming a Build Expert with David Li
More details about each featured session are coming soon in follow-up posts. We also have more surprises in the build-up to the conference, including additional on-demand content on topics like managing C++ dependencies, the status of C++23 and C++26 features in MSVC, and how to get the best out of the latest MSVC features like Sample Profile-Guided Optimizations.
Register now and join your two hosts for this year’s event, Mads Kristensen and Sinem Akinci:
Mads Kristensen is a Principal Product Manager on the Visual Studio team at Microsoft, focused on making developers more productive by improving the IDE’s performance, usability, and extensibility. He’s also a prolific open‑source contributor, known for creating popular Visual Studio extensions and tools that help both users and extension authors get more done. A frequent conference speaker, he shares practical insights with a simple goal: to make Visual Studio a fast, powerful, and enjoyable place to code.
Sinem Akinci graduated from the University of Michigan, where she studied Industrial Engineering and Computer Science. Since then, she has worked across a range of domains, including engineering and consulting, and is now a Senior Product Manager at Microsoft focused on C++ developer tooling and AI-assisted workflows across Visual Studio, VS Code, and the CLI.
Somebody told you never to call Add() in a loop. Maybe it was a senior developer during code review. Maybe it was a Stack Overflow answer with four hundred upvotes. Maybe it was a blog post that ranks on page one for “improve entity framework performance.” The advice sounded authoritative: Add() forces a DetectChanges scan on every call, and that scan gets slower as your tracked entity count grows. Switch to AddRange(), the story goes, and the cost collapses from thousands of scans down to one.
Here is what nobody mentioned when they handed you that advice: it describes Entity Framework 6. It has never applied to Entity Framework Core, not in the first release back in 2016, not in EF Core 10 running on .NET 10 today. Microsoft says so directly, in the official documentation, in plain language. The advice keeps circulating anyway, copied from post to post, because it sounds correct and almost nobody checks.
This post checks. We look at what changed between EF6 and EF Core, the specific pattern that quietly brings the old cost back under a different name, and where the real EF Core 10 performance ceiling sits once you stop worrying about the wrong method call. Entity Framework Extensions from ZZZ Projects gets honest treatment here too: this particular myth is not something EFE fixes, because native EF Core already fixed it years before this post was written. A post that claimed otherwise would be selling a solution to a problem you do not have.
The Myth, As You Have Heard It
Here is the myth, written out in full, the way it usually gets stated. Add() calls DetectChanges() on every invocation. DetectChanges() scans every tracked entity to check what changed. As your tracked set grows, each call gets a little slower, and the cost compounds. Insert ten thousand rows one at a time with Add(), and you have paid for something close to fifty million comparisons across the run. AddRange() sidesteps the problem: it hands EF Core the whole batch at once, DetectChanges() runs a single time, and the quadratic cost disappears.
That description matches real, documented behavior for Entity Framework 6. Microsoft’s own EF6 performance whitepaper recommends exactly this fix, for exactly this reason: AddRange collapses the cost of DetectChanges from one scan per entity to one scan total.
The trouble is what happened next. EF Core shipped in 2016 as a full rewrite, not an update to EF6, and the change tracker underneath it works on different principles. The advice about Add() and AddRange() did not get rewritten along with it. It got copied. Tutorials written for EF6 kept circulating. New tutorials, written for EF Core, repeated the same claim without checking whether the mechanism behind it still existed. Search “EF Core Add vs AddRange” today, and you will find both kinds, sitting side by side, with no way to tell them apart without already knowing the answer.
What Changed Between EF6 and EF Core
Here is the mechanism, stated precisely, because precision is the entire point of this post.
In EF6, both Add() and AddRange() triggered an automatic DetectChanges() call. Add() triggered one scan per call: call it a thousand times, get a thousand scans, each one walking a tracked set that keeps growing with every entity you add. That really is a quadratic cost, and it really did get slow. AddRange() batched the additions and called DetectChanges() once, after all the thousand entities were staged, not before each one. This was correct, useful and well-documented advice for anyone shipping code against EF6.
In EF Core, from the first release through EF Core 10, neither method calls DetectChanges() automatically. Not once per entity. Not once per call. Microsoft’s own change tracking documentation puts it plainly: using a range method “has the same functionality as multiple calls to the equivalent non-range method,” and the two carry no meaningful performance gap, because the scan that used to run inside both methods no longer runs inside either one by default.
This was not an accident, and it was not a side effect of some unrelated rewrite. EF Core replaced EF6’s eager, automatic-scan model with snapshot-based tracking and a small, explicit list of triggers for when a scan is needed. The team building EF Core looked at where DetectChanges cost real applications real time, and Add()-in-a-loop was not on that list once the model changed underneath it. Calling Add() a thousand times in EF Core queues up a thousand entities in the Added state. Nothing scans anything until something asks it to.
When DetectChanges Runs in EF Core 10
So when does the scan happen? EF Core documents five triggers, worth memorizing because the rest of this post depends on them: SaveChanges() and SaveChangesAsync(), ChangeTracker.Entries() and its generic overload, ChangeTracker.HasChanges(), ChangeTracker.CascadeChanges(), and the first access to a DbSet’s Local view in a given context lifetime.
A loop that calls Add() or AddRange() and touches nothing else on the change tracker pays zero DetectChanges cost per iteration. Every trigger listed above sits outside the loop body in that scenario. The single scan happens exactly once, at SaveChanges(), no matter which staging method built the candidate list.
Here is where the story earns a little more nuance, and where the myth picks up a grain of truth it does not deserve credit for. A pattern that looks completely reasonable quietly puts one of those five triggers back inside your loop.
foreach (var candidate in incoming)
{
var exists = context.Set<Customer>().Local.Any(c => c.Sku == candidate.Sku);
if (!exists)
context.Add(candidate);
}
That Local.Any() check looks like a harmless in-memory lookup against entities already staged in this context. It is not free. Accessing a DbSet’s Local view forces DetectChanges to run, and it can run again on later accesses if the tracked state changed in between. Do this once per iteration across ten thousand candidates, and you have rebuilt the exact quadratic cost the EF6-era advice describes, walking through a different door than the one usually blamed.
The same trap shows up in other shapes: calling ChangeTracker.Entries<T>() inside the loop to log what has been staged so far, calling ChangeTracker.HasChanges() as a guard condition, or calling DetectChanges() by hand, because some forum thread suggested it defensively. All three take an operation that only needed to run once and run it N times instead.
Here is the honest version, stated plainly: the change tracker gets touched inside the staging loop, and that touch is what costs you. Add() by itself never touches it. Different diagnosis, same respect for what DetectChanges costs when it runs somewhere it should not.
What the Benchmarks Show
Claims deserve numbers, and this one already has some on record. Code Maze published a benchmark comparing EF6, EF Core 6, and EF Core 7 across several staging strategies at batch sizes of 100, 1,000, and 3,000 rows against a PostgreSQL database. Their results line up with the mechanism described above closely. At every batch size on EF Core 6 and EF Core 7, Add-in-a-loop-followed-by-a-single-SaveChanges and AddRange-followed-by-a-single-SaveChanges landed within a few percent of each other, and which one came out slightly ahead flipped from one batch size to the next, the signature of measurement noise, not a real gap. A third strategy in the same benchmark, calling SaveChanges() after every single Add(), told a different story: 60 to 100 times slower than either single-SaveChanges approach, at the same batch sizes, on the same hardware.
That is third-party, unsponsored evidence for the exact claim in this post, measured independently, on the EF Core versions this post covers.
It is not enough on its own. This series runs its own BenchmarkDotNet suite against a standalone SQL Server instance before publishing any number, and this post follows the same practice. Two entity shapes get tested here: a narrow entity with three columns, and a wide entity with twelve or more, to check whether the parameter-limit effect from Post 2 (SQL Server’s 2,100-parameter ceiling per statement, which narrows the practical batch size on wide schemas) changes the Add versus AddRange comparison at all. Based on the mechanism above, it should not. Both methods reach the identical batched-INSERT code path the moment SaveChanges() runs, no matter how the entities got staged before that point.
The Real Anti-Pattern, and the Misdiagnosis It Causes
If Add() versus AddRange() is not where the real cost lives, where does it live? Right where Post 1 and Post 2 already found it: SaveChanges() called inside the loop, once per entity, instead of once after the loop finishes.
foreach (var product in incomingProducts)
{
context.Products.Add(product);
await context.SaveChangesAsync();
}
Every SaveChangesAsync() call here opens a round trip to the database and waits for a response before the loop can continue. Ten thousand products means ten thousand round trips, and swapping Add() for AddRange() changes none of that, because AddRange() only changes how entities get staged, not how often SaveChanges() gets called.
This is where the myth does its worst damage: the misdiagnosis it produces afterward. The naive starting snippet almost always contains both problems at once, Add() inside a loop and SaveChanges() inside the same loop, sitting a line or two apart. A developer who has absorbed the EF6-era advice sees Add() in a loop and recognizes it instantly as the culprit, because that is the pattern they were taught to distrust. They swap Add() for AddRange(), restructure the loop to build a list first, ship the change, and watch performance sit exactly where it was. The round-trip count never moved. The line that was expensive is still running once per entity.
Compare all three versions side by side, and the pattern becomes obvious:
// Slow: SaveChanges inside the loop
foreach (var product in products)
{
context.Products.Add(product);
await context.SaveChangesAsync();
}
// Still slow: swapping in AddRange changed nothing that mattered
foreach (var product in products)
{
context.Products.AddRange(product);
await context.SaveChangesAsync();
}
// Fast: the round trip count actually changed
context.Products.AddRange(products);
await context.SaveChangesAsync();
Before changing Add() to AddRange() anywhere in your codebase, check one thing first: where does SaveChanges() sit? Inside the loop, that is the fix, and it is the only fix that matters. Outside the loop already, AddRange() will not show up in your benchmark numbers at all.
Where AddRange Still Earns Its Place
None of this means AddRange() belongs in the trash. It means using it for a reason that holds up under measurement.
Readability is the first one, and it is a real one. context.AddRange(products) tells a reviewer, at a glance, that this is a batch operation. A foreach loop with an Add() call buried inside forces the same reviewer to read the whole block before reaching the same conclusion. Review time is real time, and clarity carries value on its own, separate from anything SaveChanges() does.
AddRange() also keeps LINQ pipelines flat. context.AddRange(products.Where(p => p.IsValid).Select(BuildEntity)) reads as one expression. Turning that same pipeline into a loop with individual Add() calls adds a foreach block and a mutable accumulator for zero behavioral gain.
There is a structural benefit too, connected directly to the Local-view trap from earlier. Build your filtered, deduplicated list first, in memory, with ordinary LINQ, and hand the finished list to AddRange() in one call. Do that, and your deduplication logic sits outside the tracked path entirely. Nothing touches Local, Entries, or HasChanges inside a loop, because there is no loop touching the change tracker in the first place. That trap cannot happen if the code that would trigger it never runs against the tracker per iteration.
Here is the quieter reason, stated plainly: Microsoft’s own documentation describes the range methods as a convenience, not a performance feature. Accept that framing and AddRange() stops being something you reach for out of fear. It becomes something you reach for because it reads better. That reason stands on its own.
Where the Real EF Core 10 Ceiling Sits
Post 2 in this series already measured where native EF Core 10 batching runs out of room, and the short version bears repeating here, because it names the ceiling that matters.
Round-trip count still counts, even with a single SaveChanges() call, because EF Core batches INSERT statements rather than eliminating them. The default batch size on SQL Server sits at 1,000 rows per statement, so two hundred thousand rows becomes two hundred round-trip: far fewer than two hundred thousand, though still more than zero. SQL Server’s 2,100-parameter ceiling per statement still applies, and it interacts directly with entity width: a ten-column entity hits that ceiling around 210 rows per batch, a fifty-column entity around 42, no matter what MaxBatchSize you configured. Memory pressure from tracked entities builds up identically whether those entities got staged through Add() or AddRange(), because both produce the same tracked state the instant SaveChanges() runs.
Add versus AddRange was never the ceiling. Round trips, the parameter limit, and change tracker memory were the ceiling the whole time, sitting exactly where this myth was pointing everyone’s attention away from.
Where Entity Framework Extensions Fits This Story, Honestly
Here it is, stated plainly, without hedging, because this series’ editorial standard requires it: this myth is not an Entity Framework Extensions feature gap. EFE does not make Add() faster than AddRange(), because native EF Core already made the two equivalent, years before this post existed. Framing EFE as the fix for a problem that does not exist would be dishonest, and this post refuses to do that.
What is honestly true instead: EFE’s BulkInsert, BulkInsertOptimized, and BulkSaveChanges skip the change tracker and the SaveChanges round-trip model completely, and that matters no matter whether your candidate list got built with Add() or AddRange(). If you have already fixed the actual anti-pattern from earlier in this post, moved SaveChanges() outside the loop, and you are still hitting a wall at genuine volume from round-trip or from tracked-entity memory, BulkSaveChanges or BulkInsert is the real next step. Post 2 in this series covers both in full, including IncludeGraph for parent-child hierarchies and the options worth knowing. This post had a narrower job: settle the Add versus AddRange question first, on terms that hold up, before anyone reaches for a library to fix something that was never broken.
Benchmark Results
All numbers below come from BenchmarkDotNet 0.15.x or later .NET 10, against a standalone SQL Server instance, not LocalDB. Two warm-up iterations, five measured, mean reported, matching the methodology used in Posts 1 and 2. CPU model, RAM, and SQL Server version get documented in full before publishing.
Narrow Entity (3 columns)
Row Count
Add loop + single SaveChanges
AddRange + single SaveChanges
SaveChanges per entity
BulkInsert (EFE)
BulkSaveChanges (EFE)
100
8.28 ms
7.56 ms
39.7 ms
38.4 ms
119.7 ms
1K
43.97 ms
45.80 ms
46.8 ms
76.4 ms
991.9 ms
10K
362.7
387.6 ms
132.2 ms
273.9 ms
24.1 s
50K
1.052 s
1.046 s
490.3 ms
614.2 ms
capped at 10K
Wide Entity (12+ columns)
Row Count
Add loop + single SaveChanges
AddRange + single SaveChanges
SaveChanges per entity
BulkInsert (EFE)
BulkSaveChanges (EFE)
100
18.38 ms
18.90 ms
65.5 ms
179.3 ms
137.2 ms
1K
122.2 ms
113.9 ms
65.4 ms
89.3 ms
1.482 s
10K
997.5 ms
919.4 ms
179.8 ms
267.1 ms
39.8 s
50K
5.876 s
4.141 s
3.705 s
1.457 s
capped at 10K
Expect Add-loop and AddRange to land within noise of each other at every row count on both tables. Expect SaveChanges-per-entity to be dramatically slower regardless of which staging method sits underneath it. Expect BulkInsert and BulkSaveChanges to pull ahead at the row counts. Post 2 is already established, with the gap widening on the wide-entity table because of the parameter-limit effect.
How to Choose
Scenario
Recommended Approach
Why
Candidate list built, single SaveChanges call at the end
Either Add in a loop or AddRange, your choice
Performance is equal; pick AddRange for readability
Loop body checks Local, Entries, or HasChanges per iteration to skip duplicates
Move the check outside the tracked path, or use BulkInsert with InsertIfNotExists (EFE)
Local view access and Entries both force a real DetectChanges scan per call
SaveChanges currently sits inside the loop
Move it outside the loop first
This is the actual round trip cost; fix it before anything else
Past roughly 50K rows, or a wide entity hitting the parameter ceiling
BulkInsert, BulkInsertOptimized, or SqlBulkCopy
Covered in full in Post 2
Existing SaveChanges-heavy codebase, want a fast win with minimal rewrite
BulkSaveChanges (EFE)
One-line swap, covered in Post 2
Production Notes
AutoDetectChangesEnabled = false is still a legitimate lever, for a different reason than commonly stated. It helps when the context already holds many previously tracked entities and something in the loop forces repeated scans, through Local, Entries, or HasChanges, not because Add() itself carries a per-call cost in a small or empty context.
Chunked SaveChanges, staging a batch of N entities, calling SaveChanges, then continuing with the next batch, is a legitimate, separate pattern used for memory management on very large imports. Do not confuse it with the anti-pattern of calling SaveChanges once per single entity. The two look similar in a code diff and differ in round-trip count by orders of magnitude.
Add() and AddRange() produce identical tracked state once SaveChanges runs. No difference in interceptor firing, validation behavior, or cascade handling between the two, because both reach the same underlying state-manager entry point per entity.
Where This Leaves You
Here it is, stated plainly one more time: the specific mechanism behind the Add versus AddRange advice, DetectChanges firing on every Add() call, described EF6, not EF Core, and it has not applied to any version of EF Core, including EF Core 10. Microsoft’s own documentation states the two approaches carry no meaningful performance gap.
The real lesson is that the myth is aimed at the wrong target. A loop that touches the change tracker per iteration through Local, Entries, or HasChanges pays a real, EF6-shaped cost today, just not for the reason usually cited. A loop with SaveChanges() called per entity pays a much larger, unrelated cost that has nothing to do with Add versus AddRange at all.
Use AddRange for readability. Fix SaveChanges placement first, always, before touching anything else. Reach for EFE’s bulk operations, covered across Posts 1 through 6 of this series, when the real ceiling, round trips, the parameter limit, or change tracker memory, is the actual constraint, not as a reflex answer to a comparison that was never really about Add versus AddRange in the first place.
Sponsored content in partnership with ZZZ Projects.
Announced at WWDC 2026, the latest SwiftUI release brings a new Document protocol for efficient disk access and snapshot-based updates, along with improved APIs for reordering items in lists, grids, and sections. In addition, it expands presentation features, such as swipe actions on any view, better AsyncImage caching, and lazy state initialization for Observable types to boost performance.
The Explorer team was investigating a crash that was occuring at a relatively high rate and found that it took the form of a thread executing from an unloaded third-party DLL.
0:173> k
RetAddr Call Site
00000000`557c5820 <Unloaded_LibUtils_CloudNs_3.dll>+0x265fe
00000000`00000008 <Unloaded_LibUtils_CloudNs_3.dll>+0x2b5820
00000000`0000000e 0x8
00000000`00000008 0xe
00000000`557c8c18 0x8
ffffffff`fffffffe <Unloaded_LibUtils_CloudNs_3.dll>+0x2b8c18
00000000`00000000 0xffffffff`fffffffe
This is just a worker thread the operates entirely inside LibDB.CloudNs.3.dll. It doesn’t have a very deep stack, so I suspect that it’s idle and is waiting for work to do.
For these types of investigations, there usually isn’t much to see directly in the crashing thread. That thread is the victim. You have to do additional research to figure out who unloaded the DLL prematurely.
Some snooping around found another stack that involves this unloaded DLL:
So the LibDB.CloudNs.3.dll that got unloaded is just part of an entire ecosystem of Lib*.CloudNs.3.dll dynamic libraries that all got unloaded together.
The ringleader of this operation appears to be CcNamespace.dll, which looks like the Contoso namespace extension that adds a “Contoso” node under My ComputerThis PC that gives you a view into all your Contoso things stored in the Contoso cloud service. All the other DLLs are helpers that the main CcNamespace.dll uses to accomplish its tasks.
The main CcNamespace.dll was loaded by Explorer as a shell extension, and its DllCanUnloadNow function was returning S_OK when there were no active references to objects in CcNamespace.dll. Unfortunately, when it said “Sure, it’s safe to unload me”, that linchpin DLL unloaded all its minions, unaware that one of the minions (the utility library) had spun up some worker threads.
You might think that the fix is to update the utility library’s DllCanUnloadNow to return S_FALSE if there are still busy background threads.¹ But that doesn’t work because the utility library is probably not a COM DLL in the first place. It’s just a traditional DLL that CcNamespace.dll uses, and it is CcNamespace.dll that is the COM DLL.
The DllCanUnloadNow in CcNamespace.dll could warn LibUtils.CloudNs.3.dll that it should start winding down, but you’re basically in a tricky spot because the DLL_PROCESS_ATTACH cannot wait for the worker thread to exit.
I think the way to go is for the worker thread to increment the DLL reference count when it starts its worker thread, and to use FreeLibraryAndExitThread to exit the worker thread. Alternatively, it could make its worker thread a threadpool thread and use FreeLibraryWhenCallbackReturns to request that the system decrement the DLL reference count when it finishes.
This is probably something the utility library should have done anyway. I suspect that the worker thread is not something that clients of the utility library are even aware of. It is just an implementation detail of the utility library, created without the knowledge of the main DLL.
Fortunately, the application compatibility team has a copy of Contoso Cloud in their library, so even though we couldn’t reproduce the crash, we were still able to confirm that CcNamespace.dll is indeed the shell extension DLL whose unloading triggers the unloading of all the dependent DLLs.
We were about to contact Contoso with our conclusions and suggestions for improvement, but we discovered that it would be pointless because Contoso discontinued that namespace extension years ago. They replaced it with a different way of integrating their cloud content into Windows; the only people using the namespace extension are those who still using an old version, either because they don’t want to pay for the upgrade, or because they are actively avoiding the upgrade because they like the old way.
Those customers are using a product that has gone out of support. Contoso doesn’t care about those old customers any more. Windows will have to fix it without Contoso’s help.
The Explorer team added an application compatibility flag for the Contoso Cloud namespace extension to say “When you load this shell extension, do a GetModuleHandleEx with the GET_MODULE_HANDLE_EX_FLAG_PIN flag so the DLL never unloads.” That way, even if the DLL says “Sure, go ahead and unload me, it’s totally safe, trust me,” and COM does a FreeLibrary, the DLL doesn’t actually unload.
¹ Even if you manage to get return DllCanUnloadNow to return S_FALSE, it doesn’t help if COM is being uninitialized. In that case, CoUninitalize will ask a DLL if it is okay to unload now, but the answer is a foregone conclusion: If COM is shutting down, COM is going to unload all the DLLs that it loaded. It asks you if you are okay with it, not because it cares what your answer is, but to give you a chance to do cleanup outside of DllMain.