A code review of warning policy: TreatWarningsAsErrors versus WarningsAsErrors and WarningsNotAsErrors, NoWarn versus severities in .editorconfig, CI-only versus local enforcement, AnalysisLevel pinning, and ratcheting a legacy codebase to zero warnings.
The file under review turns on TreatWarningsAsErrors and then hides 25 warnings with NoWarn. That list includes nullable warnings, obsolete-API warnings and a security rule, so the policy looks strict while silencing the warnings that matter most. I'd remove those suppressions. Rules that genuinely don't apply would get a severity in .editorconfig, scoped to the right folders and commented. Rules we can't fix yet would go in WarningsNotAsErrors, so they stay visible as warnings. I'd also pin AnalysisLevel so an SDK upgrade can't break the build overnight, apply the same settings locally and in CI, and ratchet a legacy codebase towards zero warnings rather than flipping everything at once.
"My approach is to look past the headline
TreatWarningsAsErrorsto what's being suppressed and why. The mistake I see is exactly this: a longNoWarnlist that hides nullable warnings,CS0618obsolete APIs, theCA2100SQL rule andNU1902audit findings, so the build looks strict while the team learns to ignore the compiler. Known debt belongs inWarningsNotAsErrorsso it stays visible, a legacy ratchet usesWarningsAsErrorsfor chosen groups likenullable, and genuine exceptions go in.editorconfigwith a scope and a comment; I'd also pinAnalysisLevelinstead oflatest-all, enforce the same settings locally and in CI, make NU1903 and NU1904 errors, and move the opt-in condition toDirectory.Build.targets, because props are evaluated before the project sets it.For example, our [identity service at company] looked strict, with warnings as errors in CI, but a [40-entry]
NoWarnlist was hiding [900] warnings. Before a framework upgrade, my task was to find out what we couldn't see. I removed the suppressions on a branch, grouped the warnings, and found an obsolete authentication API and [two] SQL injection risks, which I fixed first. I moved the remaining debt toWarningsNotAsErrorswith owners, pinned the analysis level and set up a CI ratchet that fails if a project's warning count goes up. As a result, the upgrade went through without surprises and suppressions dropped from [40] to [5] justified entries.Warnings as errors is only valuable if the warnings that remain are meaningful, so security, nullable and high-severity audit findings are never suppressed, and the exceptions list has an owner and keeps shrinking."
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<AnalysisLevel>latest-all</AnalysisLevel>
<TreatWarningsAsErrors Condition="'$(CI)' == 'true'">true</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CS8600;CS8602;CS8603;CS8618;CS0618;CA2100;CA1848;CA2007;CA1062;NU1603;NU1902;...</NoWarn>
</PropertyGroup>
</Project>
| # | Issue | Why it matters | Change |
|---|---|---|---|
| 1 | Nullable warnings (CS8600, CS8602, CS8603, CS8618) in NoWarn |
Nullable=enable is pointless if its warnings are hidden |
Remove; fix, or use WarningsNotAsErrors temporarily for projects still migrating |
| 2 | CS0618 (obsolete API) hidden |
You won't notice APIs that will be removed in the next version | Remove; track and replace usages |
| 3 | CA2100 hidden |
SQL injection risk rule silenced repo-wide | Remove; set to error |
| 4 | NU1902 hidden |
Hides known moderate-severity vulnerabilities in packages | Remove; decide policy for audit warnings |
| 5 | AnalysisLevel=latest-all with warnings as errors |
Every SDK upgrade can add new rules and break builds | Pin 10.0-recommended, upgrade deliberately |
| 6 | Warnings as errors only when CI=true |
Developers find failures only after pushing | Same setting locally; use the IDE to see them early |
| 7 | CA2007, CA1848, CA1062 in a single global list |
Might be valid for some projects, not others | Move to .editorconfig with a comment per rule and folder scoping |
TreatWarningsAsErrors=true: all warnings are errors.WarningsAsErrors=<ids or nullable>: only these are errors (use when you can't turn on everything yet).WarningsNotAsErrors=<ids>: with TreatWarningsAsErrors, these stay warnings, visible but not blocking.NoWarn=<ids>: the warning is not reported at all..editorconfig dotnet_diagnostic.<id>.severity: per rule, per folder, for analyser diagnostics (and many compiler warnings).<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<AnalysisLevel>10.0-recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Visible, not blocking, while we pay down these specific items. Owner: platform team, review each quarter -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS0618;NU1902</WarningsNotAsErrors>
</PropertyGroup>
</Project>
and in .editorconfig:
[*.cs]
dotnet_diagnostic.CA2100.severity = error
dotnet_diagnostic.CA2007.severity = none # ASP.NET Core: no SynchronizationContext
TreatWarningsAsErrors for projects already at zero.WarningsAsErrors=nullable, security rules).A repo had a 40-entry NoWarn list accumulated over years. Removing it in a branch surfaced 900 warnings, including CS0618 on an authentication API scheduled for removal and two CA2100 hits. The team fixed the security items immediately, moved CS0618 to WarningsNotAsErrors with a ticket, and ratcheted the rest over two sprints.
Tests whether the candidate can review build policy, not just code.
Green flags: spots nullable and security suppressions; knows the difference between NoWarn, WarningsAsErrors and WarningsNotAsErrors; pins AnalysisLevel; proposes ratcheting; notices CI-only enforcement and the evaluation-order bug.
Red flags: "looks strict, approve"; suggests adding more NoWarn entries; enforces everything at once.
A single dotnet build collects diagnostics from several producers, and they don't all obey the same switches:
| Producer | IDs | Controlled by |
|---|---|---|
| C# compiler | CS0xxx, CS8xxx (nullable is CS86xx) |
TreatWarningsAsErrors, WarningsAsErrors, WarningsNotAsErrors, NoWarn, #pragma, .editorconfig |
| .NET SDK analysers | CAxxxx (quality), IDExxxx (style) |
The same, plus AnalysisLevel, AnalysisMode, EnforceCodeStyleInBuild |
| Third-party analysers | for example SAxxxx, xUnitxxxx |
The same, through .editorconfig and the MSBuild properties |
| NuGet restore | NUxxxx (audit is NU1901-NU1904) |
TreatWarningsAsErrors, WarningsAsErrors, NoWarn, plus NuGetAudit* properties |
| MSBuild itself | MSBxxxx |
MSBuildTreatWarningsAsErrors, MSBuildWarningsAsErrors, MSBuildWarningsAsMessages, or -warnaserror on the command line |
The last row surprises people: TreatWarningsAsErrors doesn't promote MSBuild's own warnings, such as MSB3277 assembly version conflicts. Use MSBuildTreatWarningsAsErrors for those, or dotnet build -warnaserror in CI, which applies to everything.
diagnostic produced (compiler or analyser), default severity
|
.editorconfig / .globalconfig severity for this file?
none | silent | suggestion | warning | error
|
v
effective severity
|
#pragma warning disable or [SuppressMessage] here? --yes--> suppressed
| no
NoWarn contains ID? --yes--> dropped (not reported at all)
| no
WarningsAsErrors contains ID (or 'nullable')? --yes--> error
| no
TreatWarningsAsErrors = true?
| yes | no
v v
WarningsNotAsErrors contains ID? warning
| yes -> warning | no -> error
Three consequences:
TreatWarningsAsErrors only promotes things that are warnings. A rule set to suggestion in .editorconfig stays a suggestion, so you can keep a rule visible in the IDE without blocking the build.NoWarn is the bluntest tool: the diagnostic disappears from every log, every report and every IDE.WarningsNotAsErrors keeps a diagnostic visible as a warning, which is usually what you want for known debt.| Tool | Scope | Visible later? | Good for |
|---|---|---|---|
#pragma warning disable CA2100 // reason ... restore |
A few lines | Yes, in the code | One justified exception |
[SuppressMessage("Security", "CA2100", Justification = "...")] |
A member or type | Yes, with a required justification | A reviewed exception on one method |
.editorconfig section such as [src/Legacy/**.cs] |
A folder or file pattern | Yes, in a config file | A rule that doesn't fit one area (generated code, tests) |
WarningsNotAsErrors |
Project or repo | Yes, still reported as a warning | Known debt being paid down |
NoWarn |
Project or repo | No | Rules that genuinely never apply to the codebase |
When you use pragmas, turn on IDE0079 ("remove unnecessary suppression") so suppressions that no longer hide anything get cleaned up.
AnalysisLevel=latest-all + TreatWarningsAsErrors=true
SDK 10.0.1xx --> rules A, B, C enabled build green
SDK 10.0.2xx --> rules A, B, C, D enabled build RED, no code changed
AnalysisLevel=10.0-recommended
SDK 10.0.2xx --> rule set frozen at 10.0 build green
later: a pull request changes it to 11.0-recommended and fixes the new warnings
latest follows whichever SDK happens to build the code, so an agent image update or a new developer machine can break the build. A pinned value like 10.0-recommended makes the rule set part of the source, and upgrading it becomes a reviewed change. Pin the SDK in global.json as well, because new compiler warnings can arrive with a new SDK band even when AnalysisLevel is pinned.
NuGet checks restored packages against known vulnerabilities:
| ID | Severity |
|---|---|
| NU1901 | Low |
| NU1902 | Moderate |
| NU1903 | High |
| NU1904 | Critical |
Related properties: NuGetAudit (on by default), NuGetAuditLevel (minimum severity reported) and NuGetAuditMode (direct or all; the .NET 10 SDK audits transitive packages too by default).
A sensible policy is to make NU1903 and NU1904 errors with WarningsAsErrors, keep NU1901 and NU1902 as visible warnings with an owner, and never put any of them in NoWarn. When a specific advisory doesn't apply to you, suppress that advisory with <NuGetAuditSuppress Include="https://github.com/advisories/GHSA-..." /> and a comment, rather than hiding every future vulnerability of that severity.
If only CI promotes warnings to errors (Condition="'$(CI)' == 'true'"), developers get a green local build and a red pipeline about ten minutes later. The feedback loop gets longer and pull requests pick up "fix warnings" commits.
Use the same settings everywhere. If strictness gets in the way of quick experiments, the IDE's error list shows the problems as you type, and a developer can still build a scratch branch with -p:TreatWarningsAsErrors=false on purpose. The default should be identical.
week 0 measure: warnings per project (build log or SARIF)
Orders.Api 412 Billing 37 Shared 0 Reports 1,980
week 1 Shared: TreatWarningsAsErrors=true (already at zero)
everyone: WarningsAsErrors=nullable;CA2100;CA3001;NU1903;NU1904
CI: fail if any project's count goes UP
week n Billing reaches 0 -> strict; Orders.Api 412 -> 120 -> 0 -> strict
end TreatWarningsAsErrors in Directory.Build.props, opt-ins deleted
To produce countable output, set <ErrorLog>$(MSBuildProjectName).sarif,version=2.1</ErrorLog> so the compiler writes a SARIF file per project, or build with -bl and inspect the binary log. A small script compares the counts with a baseline file committed in the repo and fails when any number rises. Lower the baseline in the same pull request that fixes warnings.
| Symptom | Likely cause | Check |
|---|---|---|
A warning still appears despite NoWarn |
It's an MSBuild warning (MSBxxxx), or NoWarn is overwritten later without $(NoWarn); |
dotnet msbuild -getProperty:NoWarn and look for assignments that drop the existing value |
| Build red only on CI | CI-only conditions, a different SDK, or -warnaserror in the pipeline |
Compare dotnet --info and the build command |
| Build red after an SDK update, no code change | AnalysisLevel=latest, or new compiler warnings in the new band |
Pin AnalysisLevel and the SDK |
| An IDE rule doesn't fail the build | EnforceCodeStyleInBuild is off, or the rule is at suggestion |
Set it to true and the severity to warning |
| Strict opt-in property seems ignored | The condition is in Directory.Build.props, evaluated before the project sets the property |
Move the condition to Directory.Build.targets |
-p:ReportAnalyzer=true if builds slow down.CA2100, CA3001-CA3012 injection rules, CA5xxx cryptography rules) and high and critical NuGet audit findings should be errors and should never appear in NoWarn.Directory.Build.props, Directory.Build.targets and .editorconfig under CODEOWNERS..NET 11 (due in November 2026, an STS release with 24 months of support, currently in preview and release candidates) will add analyser rules and compiler warnings, as every release does. With AnalysisLevel and global.json pinned to .NET 10, they reach the build only when a pull request raises the pins and fixes what appears.
Nullable enabled and a method that dereferences a string?.-p:TreatWarningsAsErrors=true and see CS8602 as an error.<WarningsNotAsErrors>CS8602</WarningsNotAsErrors> and build: it's a warning again.<NoWarn>CS8602</NoWarn> and build: the warning is gone entirely, including from the IDE.<ErrorLog>$(MSBuildProjectName).sarif,version=2.1</ErrorLog>, remove NoWarn, build and open the SARIF file to see a countable list.WarningsAsErrors and see restore fail.<!-- Directory.Build.props: repo-wide defaults (evaluated BEFORE each project file) -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<AnalysisLevel>10.0-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- Always errors, in every project -->
<WarningsAsErrors>$(WarningsAsErrors);nullable;CA2100;NU1903;NU1904</WarningsAsErrors>
</PropertyGroup>
</Project>
<!-- Directory.Build.targets: evaluated AFTER each project file, so it can see the project's opt-in -->
<Project>
<PropertyGroup Condition="'$(StrictWarnings)' == 'true'">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS0618</WarningsNotAsErrors> <!-- tracked: TICKET-1234 -->
</PropertyGroup>
</Project>
<!-- A project that reached zero warnings opts in -->
<PropertyGroup>
<StrictWarnings>true</StrictWarnings>
</PropertyGroup>
The opt-in condition lives in Directory.Build.targets because a condition in Directory.Build.props would run before the project file sets StrictWarnings, and would never match. Once every project has opted in, move TreatWarningsAsErrors into Directory.Build.props and delete the opt-in.
Mistake: a long, unexplained NoWarn list.
Each entry hides a whole rule in every file, forever. New nullable, obsolete-API and security problems arrive silently, nobody knows whether the original reason still applies, and the team learns that warnings are noise. The list only grows.
Instead: remove suppressions that hide real problems, scope genuine exceptions in .editorconfig or with [SuppressMessage(Justification = ...)], and give each one a comment and an owner.
Mistake: using NoWarn for known debt.
The debt becomes invisible, so nobody pays it down and the number of real occurrences keeps rising.
Instead: put it in WarningsNotAsErrors, so it is still reported as a warning but doesn't block the build, and link it to a ticket.
Mistake: combining AnalysisLevel=latest with warnings as errors.
New rules arrive with every SDK update, so a build can go red on an agent image change with no code change.
Instead: pin AnalysisLevel (for example 10.0-recommended) and the SDK in global.json, and raise them in a dedicated pull request that fixes the new warnings.
Mistake: enforcing warnings as errors only in CI. Developers get a green local build and learn about failures after pushing, which slows every pull request. Instead: use the same settings locally and in CI, and rely on the IDE error list for fast feedback.
Mistake: turning on TreatWarningsAsErrors across a legacy repo in one step.
Thousands of errors appear, and the quickest way back to green is mass suppression, which is worse than where you started.
Instead: ratchet. Make the most important categories errors first (nullable, security, high and critical NuGet audit), make clean projects strict, and fail CI when a project's warning count rises.
Mistake: putting an opt-in condition in Directory.Build.props.
The props file is evaluated before the project file, so a property the project sets (such as StrictWarnings) is still empty when the condition runs, and the opt-in never works.
Instead: put conditions that depend on project settings in Directory.Build.targets.
Mistake: overwriting NoWarn or WarningsAsErrors without the existing value.
<NoWarn>CA2007</NoWarn> in a project throws away the SDK's defaults and the repo-wide list.
Instead: always append: <NoWarn>$(NoWarn);CA2007</NoWarn>.
Mistake: ignoring or blanket-suppressing NuGet audit warnings.
NoWarn=NU1902;NU1903 hides every future vulnerability of that severity, not only the one you reviewed.
Instead: make NU1903 and NU1904 errors, update the package, and when an advisory truly doesn't apply, suppress that advisory alone with NuGetAuditSuppress and a comment.
Mistake: assuming TreatWarningsAsErrors covers everything.
MSBuild's own warnings (such as MSB3277 version conflicts) aren't promoted by it, so they pile up unnoticed.
Instead: add MSBuildTreatWarningsAsErrors or build with -warnaserror in CI when you want those to fail too.
Warnings-as-errors is only valuable if the warnings that remain are meaningful. A senior reviewer looks past the headline TreatWarningsAsErrors=true to what's being suppressed and why, and pushes the policy towards: pinned analysis level, security and nullable always errors, known debt visible as warnings with an owner, and the same behaviour locally and in CI.
In a review of a change like this, the questions they ask are:
WarningsNotAsErrors (still shown) rather than NoWarn (gone).In a legacy codebase they ratchet rather than flip, because a policy that forces mass suppressions on day one teaches the team to ignore the compiler.
Analysers are plug-ins for the Roslyn compiler. They report diagnostics in the editor and during the build. CA rules (CA1xxx to CA5xxx) are the SDK's code quality analysers, covering design, performance, reliability and security. The .NET analysers are enabled by default for net5.0+ projects (EnableNETAnalyzers), but only a default subset of rules reports as warnings; AnalysisLevel and AnalysisMode set how many apply. IDE rules (IDE0xxx) are code style rules, such as var usage or file-scoped namespaces. They show in the editor but only run in the build when EnforceCodeStyleInBuild is true, which is why style issues often pass CI. Severities for both are set in .editorconfig (or a .globalconfig) with dotnet_diagnostic.<id>.severity. Third-party analysers come in as NuGet packages. For a team, I put the levels in Directory.Build.props, the severities in one root config file, and tighten them step by step.
<Nullable>enable</Nullable> makes reference types non-nullable by default: string? means a value can be null and string means it can't. The compiler then warns when code might dereference null. It's purely compile-time, so nothing changes at run time. In a big codebase I migrate bottom-up: shared libraries and domain types first, because their annotations flow out to everyone else. I go project by project, or file by file with #nullable enable, and give each step a small pull request so feature work carries on. When a project is clean I make its nullable warnings errors with WarningsAsErrors=nullable, so it can't slip back. I use attributes such as [NotNullWhen] where flow analysis needs help and keep ! for real boundaries. I also watch for two things: EF Core migrations, where nullability decides whether columns are required, and public APIs, where annotations are part of the contract.
Directory.Build.props and Directory.Build.targets are ordinary MSBuild files that the SDK imports automatically into every project below them. For each project, MSBuild searches upwards and uses the nearest one it finds. .props is imported early, before the project's own content, so it's for default properties and items: target framework, Nullable, analysers, package metadata. Projects can still override those values. .targets is imported late, after the project, so it's for targets and logic that need final values. Only the nearest file is picked up. To layer them, the inner file imports the outer one with GetPathOfFileAbove. I debug them with dotnet msbuild -preprocess or a binary log.
How to tell it (STAR)
**Situation:** Our [identity service] at [company] looked strict, with warnings as errors in CI, but a [40-entry] NoWarn list was hiding [900] warnings. **Task:** Before a framework upgrade, I reviewed the warning policy to find out what we couldn't see. **Action:** I removed the suppressions on a branch, grouped the warnings, and found an obsolete authentication API and [two] SQL injection risks. I fixed those first, moved the remaining debt to WarningsNotAsErrors with owners, pinned the analysis level and set up a ratchet in CI. **Result:** The upgrade went through without surprises, the security issues were closed [before release], and suppressions dropped from [40] to [5] justified entries.
Last updated 1 Nov 2026.