← Back to Blog

The Hidden Junk in Your .csproj: What You Can Delete and What You Can't

Most .csproj files accumulate years of copy-pasted properties nobody remembers adding. Some are genuinely dead weight. Some look dead and aren't. Here's how to tell the difference before you delete anything.

Pankaj Kumar
Senior Software Engineer — .NET, Blazor, ASP.NET Core
4+ years building production .NET and Blazor applications. Every DevToolsHub tool and article comes from real daily development work — not documentation summaries.
Published 07 Aug 2026· Last reviewed Aug 2026· 7 min read · About the author →
I inherited a .csproj with fourteen properties in the top PropertyGroup. Four were load-bearing overrides for a build server nobody had used in two years. The other ten were restating defaults the SDK already applied.
Key takeaways
  • SDK-style projects (Microsoft.NET.Sdk) ship sane defaults for properties like OutputType, GenerateAssemblyInfo, and IsPackable — explicitly restating the default value does nothing except add noise
  • A property inside a <PropertyGroup Condition="..."> is a deliberate per-configuration override, never a redundant default, even if its value happens to match the unconditional default
  • The same PackageReference under two different TargetFramework conditions is normal multi-targeting, not a duplicate — only flag duplicates within the same condition
  • Legacy elements like ProjectGuid, TargetFrameworkVersion, and explicit Compile Include are signs of an incompletely migrated pre-SDK project — reviewable, not automatically deletable
  • Static analysis of a .csproj's XML can't evaluate MSBuild conditions or resolve your actual installed SDK version — treat findings as a strong starting point, not a guarantee
Table of Contents

Where the junk comes from

Every .csproj file I've inherited past its third year has the same shape: a <PropertyGroup> with a handful of properties nobody added on purpose. Someone copied a snippet from an answer targeting an older SDK. A previous developer set <LangVersion>latest</LangVersion> to fix a compiler error that had a different actual cause. A template from a few SDK versions ago wrote defaults that have since become, well, the actual defaults. None of this is anyone's fault — it accumulates the same way any codebase accumulates dead code, just in XML instead of C#, which makes it easier to ignore because it doesn't show up in code review the same way a stray line of C# would.

What "SDK-style" actually means for defaults

Since the SDK-style project format (<Project Sdk="Microsoft.NET.Sdk">) became the norm, a large number of properties that used to require explicit values now have sane built-in defaults supplied by the SDK itself. You don't need to tell the compiler your output is a library — that's already the default unless you set OutputType to Exe. You don't need to turn on deterministic builds — Roslyn does that by default. Writing the default value explicitly isn't wrong, exactly; it just adds a line that reads like a deliberate decision to the next person opening the file, when it was actually a no-op.

The properties that are genuinely safe to delete

Inside an unconditional <PropertyGroup> — one with no Condition attribute — these are restating what the SDK already does, and removing them changes nothing about your build:

<!-- All of these match the SDK's own default — safe to delete -->
<OutputType>Library</OutputType>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<Deterministic>true</Deterministic>
<LangVersion>latest</LangVersion>

The important qualifier is unconditional. The exact same <Nullable>disable</Nullable> inside a <PropertyGroup Condition="'$(Configuration)'=='Debug'"> block isn't redundant at all — it's a deliberate override for one configuration, and it needs to stay even though it happens to match what the unconditional default would be. A static check that flags every occurrence of these values regardless of context will generate false positives on exactly this pattern, which is why the Csproj Analyzer on this site only checks properties inside condition-free property groups.

Duplicate PackageReferences — and the one that only looks like a duplicate

A genuine duplicate — the same package listed twice with the same version, usually from a merge conflict resolved by keeping both lines, or two people adding the same NuGet package in separate PRs — is unambiguous and always safe to collapse to one line. But there's a shape that looks identical at a glance and isn't a bug at all:

<ItemGroup Condition="'$(TargetFramework)'=='net8.0'">
  <PackageReference Include="System.Text.Json" Version="8.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)'=='net472'">
  <PackageReference Include="System.Text.Json" Version="6.0.0" />
</ItemGroup>

This is a multi-targeted project pinning a different package version per target framework — completely normal, and arguably the entire point of multi-targeting. A duplicate check that only looks at the package name without also checking the surrounding Condition will flag this as a false duplicate and lead you to delete a version pin that a different build actually needs. Any duplicate-reference check worth using has to compare package name and the enclosing item group's condition together, not name alone.

Legacy elements: signs of an incomplete migration, not automatic deletes

A handful of elements are near-certain signs a project was migrated from the old pre-SDK MSBuild format and never fully cleaned up: <ProjectGuid> (project identity isn't tracked this way anymore), <TargetFrameworkVersion> (SDK-style projects use <TargetFramework>net8.0</TargetFramework> instead), explicit <Compile Include="..." /> items (SDK-style projects include all .cs files under the project directory automatically — an explicit list is either leftover or intentionally excluding something), and a legacy <Import Project="...MSBuildToolsPath..." /> that SDK-style projects don't need, since the Sdk="Microsoft.NET.Sdk" attribute imports the build targets implicitly.

These are worth reviewing, not worth auto-deleting. An explicit Compile Include might be intentionally excluding a file elsewhere in the tree that shouldn't compile — deleting it blind could silently add that file back into the build. This is exactly why a responsible static check lists these for your judgment instead of rewriting them for you.

The junk that isn't even in the file you're looking at

Not every property affecting your project lives in its own .csproj. MSBuild automatically imports a Directory.Build.props file from any ancestor directory, and any property set there applies to every project underneath it — silently, with no reference in the .csproj itself. If a property you're staring at in a specific project's file looks redundant but genuinely isn't matching the SDK default you expect, check whether a sibling or parent Directory.Build.props is setting a different baseline first; a property that looks like a pointless override in one project might be intentionally opting out of a repo-wide default set two directories up.

The inverse problem shows up with NuGet package versions: a large solution with a dozen projects duplicating the same <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> line across every .csproj isn't a bug the analyzer above will catch, since each occurrence is in a different file. Central Package Management — a Directory.Packages.props file at the solution root with <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> and a single <PackageVersion> entry per dependency — moves the version number out of every individual .csproj entirely, leaving just <PackageReference Include="Newtonsoft.Json" /> with no version attribute in each project. It's the direct fix for the "which of these twelve projects has the stale version" problem that accumulates in any multi-project solution old enough to have added the same package more than once.

One distinction worth knowing when you go looking for where a property actually came from: Directory.Build.props is imported at the very top of your project, before any of the project's own <PropertyGroup> content is evaluated, so anything your .csproj sets explicitly overrides it. Directory.Build.targets, if present, is imported at the very end, after everything in the .csproj — which means it can override a value your own project just set, not just supply a fallback for one it left unset. If a property in your .csproj appears to have no effect at all, a Directory.Build.targets further up the tree quietly overwriting it afterward is worth ruling out before assuming the .csproj itself is broken.

What a static analyzer can't know

A tool that parses your .csproj as XML — without invoking MSBuild, without restoring NuGet packages, without evaluating the project against your actual installed SDK version — cannot know your SDK's version-specific defaults with certainty, cannot evaluate complex MSBuild conditions beyond simple string comparison, and cannot tell whether a "redundant" property was deliberately made explicit for documentation purposes. Treat any list of "safe to remove" properties, from any tool including this one, as a strong starting point that you verify with a build and a test run — not a guarantee. The same caution applies to comparing settings across environments — see the appsettings.json drift post for what happens when nobody double-checks a config value that "looks fine."

A five-minute cleanup

Paste your .csproj into the Csproj Analyzer & Cleaner: it flags duplicate references, SDK-default properties, and legacy elements separately, then generates a cleaned version with the confirmed-safe removals already applied — legacy elements are listed for your review but never auto-removed, for the reason above. While you're at it, a .gitignore that hasn't been touched since the project's first commit is worth a similar look — the Gitignore Generator catches the same kind of stale-defaults problem for build output directories instead of MSBuild properties. Neither of these bugs is dramatic on its own, but a bloated or stale project file makes every future diff harder to read and every "why is this here" question harder to answer — the same quiet cost as a connection string nobody has revisited since the project started.

Try the free tool

Csproj Analyzer & Cleaner

Paste a .csproj file to find duplicate PackageReferences, SDK-default properties safe to remove, and legacy non-SDK elements — with a cleaned version generated automatically.

Open Csproj Analyzer & Cleaner