SDK-Style Project Files

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

An SDK-style project is an MSBuild project whose root element names a project SDK: <Project Sdk="Microsoft.NET.Sdk">. That one attribute imports thousands of lines of MSBuild logic, so a project file can state only what is particular to the project — its target framework, its packages, its metadata — and inherit everything else: which files to compile, where outputs go, how to restore, pack and publish. All .NET (Core) and .NET 5+ projects are SDK-style; the format can also target .NET Framework and .NET Standard. This page dissects the format with the Ledgerline projects and .NET SDK 10.0.402. The introduction is in Namespaces, Assemblies and Projects — The SDK-Style Project; the MSBuild elements themselves (PropertyGroup, ItemGroup, Target, Import) are covered on Project File Anatomy.

The smallest project

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
</Project>

These five lines build a library from every *.cs file under the project folder. The Sdk attribute tells MSBuild to add two implicit imports: Sdk.props before the first line of the project body and Sdk.targets after the last. dotnet msbuild -pp:<file> ("preprocess") writes the project with every import inlined and shows them:

dotnet msbuild -pp:pp.xml
wc -l pp.xml
  <!--
============================================================================================================================================
  <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk">
  This import was added implicitly because the Project element's Sdk attribute specified "Microsoft.NET.Sdk".

<sdk>/Sdks/Microsoft.NET.Sdk/Sdk/Sdk.props
============================================================================================================================================
-->
…
  <!--
============================================================================================================================================
  <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk">
  This import was added implicitly because the Project element's Sdk attribute specified "Microsoft.NET.Sdk".

<sdk>/Sdks/Microsoft.NET.Sdk/Sdk/Sdk.targets
============================================================================================================================================
-->
17284 pp.xml

The five-line project is 17,284 lines once evaluated. The preprocessed file is the first thing to read when a property "has no effect": search it for the property name and the last assignment wins.

The three equivalent forms

The Sdk attribute is shorthand. The same project can be written with explicit imports, which is the only way to put something after Sdk.targets — for example a target that overrides one the SDK defines:

<Project>
  <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>

  <!-- Anything here can override what Sdk.targets defines, because it comes after it. -->
  <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />

  <Target Name="ShowSdk" AfterTargets="Build">
    <Message Importance="high" Text="Built with $(NETCoreSdkVersion) for $(TargetFramework)" />
  </Target>
</Project>
  Determining projects to restore...
  Restored ~/journal/Journal.csproj (in 82 ms).
  Journal -> ~/journal/bin/Debug/net10.0/Journal.dll
  Built with 10.0.402 for net10.0
Build succeeded.

The third form is the <Sdk> element, used to add a second SDK on top of the first ("additive SDKs") or to give a version: <Sdk Name="Aspire.AppHost.Sdk" Version="13.6.3" /> inside a <Project Sdk="Microsoft.NET.Sdk">. A version can also be written in the attribute itself, <Project Sdk="Microsoft.Build.NoTargets/3.7.134">.

How a project SDK is found

When MSBuild evaluates an SDK import it asks a chain of SDK resolvers:

  1. the .NET SDK resolver finds the SDKs that ship with the selected .NET SDK (Microsoft.NET.Sdk, Microsoft.NET.Sdk.Web, …) under <sdk>/Sdks/;

  2. the NuGet SDK resolver downloads an SDK published as a NuGet package (Microsoft.Build.Traversal, Microsoft.Build.NoTargets, your own) — only when a version is given, in the project or in the msbuild-sdks section of global.json;

  3. the default resolver looks next to MSBuild itself.

Without a version, a NuGet-delivered SDK fails to resolve, and the error lists what each resolver said:

<Project Sdk="Microsoft.Build.NoTargets">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
</Project>
~/demo/Docs.proj : error : Could not resolve SDK "Microsoft.Build.NoTargets". Exactly one of the probing messages below indicates why we could not resolve the SDK. Investigate and resolve that message to correctly specify the SDK.
~/demo/Docs.proj : error :   SDK resolver "Microsoft.DotNet.MSBuildWorkloadSdkResolver" returned null.
~/demo/Docs.proj : error :   The NuGetSdkResolver did not resolve this SDK because there was no version specified in the project or global.json.
~/demo/Docs.proj : error MSB4236: The SDK 'Microsoft.Build.NoTargets' specified could not be found.

The fix is a version in one place for the whole repository:

{
  "msbuild-sdks": {
    "Microsoft.Build.NoTargets": "3.7.134",
    "Microsoft.Build.Traversal": "4.1.82"
  }
}

Only one version of each project SDK can be used in a build; MSBuild warns when two projects ask for different ones. Traversal and NoTargets are covered on Imports and Project SDKs.

The SDKs that ship with .NET

SDK For Adds on top of Microsoft.NET.Sdk

Microsoft.NET.Sdk

libraries, console apps, tests, tools; also WinForms and WPF with UseWindowsForms/UseWPF

 — 

Microsoft.NET.Sdk.Web

ASP.NET Core apps

the ASP.NET Core shared framework, web publishing, wwwroot content items, Razor

Microsoft.NET.Sdk.Razor

Razor class libraries

Razor compilation

Microsoft.NET.Sdk.BlazorWebAssembly

Blazor WebAssembly apps

the WebAssembly build and publish pipeline

Microsoft.NET.Sdk.Worker

worker services

the Microsoft.Extensions.Hosting usings

Aspire.AppHost.Sdk

Aspire app hosts

orchestration of the referenced projects (used as an additive SDK)

MSTest.Sdk

MSTest test projects

the MSTest packages and the Microsoft.Testing.Platform runner; it is delivered through NuGet, so it needs a version (MSTest.Sdk/4.5.1, or msbuild-sdks in global.json)

There is no separate SDK for desktop apps any more: a WinForms or WPF project uses Microsoft.NET.Sdk with a Windows TFM and a switch. The project builds only on Windows (and is not run here):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net10.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <!-- or: <UseWindowsForms>true</UseWindowsForms> -->
  </PropertyGroup>
</Project>

Default items

An SDK-style project does not list its files. Microsoft.NET.Sdk.DefaultItems.props defines three item types with globs relative to the project folder:

Item Include Exclude Remove

Compile

**/*.cs

**/*.user; **/*.*proj; **/*.sln(x); **/*.vssscc

 — 

EmbeddedResource

**/*.resx

same

 — 

None

**/*

same

**/*.cs; **/*.resx

bin/ and obj/ ($(BaseOutputPath) and $(BaseIntermediateOutputPath)) are excluded through DefaultItemExcludes. Querying the evaluated items of src/Ledgerline shows the result — the five .cs files as Compile, and the files Directory.Build.targets adds (README and icon, for packing) as None:

dotnet msbuild -p:TargetFramework=net10.0 -getProperty:DefaultItemExcludes -getItem:Compile -getItem:None
"DefaultItemExcludes": ";bin\\Debug//**;obj\\Debug//**;bin\\/**;obj\\/**;bin\\Debug/net10.0/publish//**;**/*.user;**/*.*proj;**/*.sln;**/*.slnx;**/*.vssscc;**/.DS_Store"
Compile ['IsExternalInit.cs', 'JournalLine.cs', 'Ledger.cs', 'Ledger.Json.cs', 'LedgerReader.cs']
None ['packages.lock.json', '~/ledgerline/README.md', '~/ledgerline/icon.png']

(Here and below, JSON output of -getItem is condensed to the item identities.)

The properties that switch the defaults off are EnableDefaultItems (all three), EnableDefaultCompileItems, EnableDefaultEmbeddedResourceItems and EnableDefaultNoneItems. To customise, modify the default items instead of re-adding them: <Compile Remove="Legacy/**" /> to exclude a folder, <None Update="journal.ledger.csv" CopyToOutputDirectory="PreserveNewest" /> to change metadata (as Ledgerline.Report does).

Adding a file that the glob already includes — typically when an old project file is converted by hand — is an error:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <!-- Copied from an old-style project: the SDK already includes **/*.cs -->
    <Compile Include="Ledger.cs" />
  </ItemGroup>
</Project>
<sdk>/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.DefaultItems.Shared.targets(190,5): error NETSDK1022: Duplicate 'Compile' items were included. The .NET SDK includes 'Compile' items from your project directory by default. You can either remove these items from your project file, or set the 'EnableDefaultCompileItems' property to 'false' if you want to explicitly include them in your project file. For more information, see https://aka.ms/sdkimplicititems. The duplicate items were: 'Ledger.cs' [~/journal/Journal.csproj]

A related trap: because the globs are recursive, a nested project folder (a test project placed inside the library folder, or a samples/ folder below it) has its .cs files compiled into the outer project too. Keep projects in sibling folders, as Ledgerline does, or remove the folder with <Compile Remove="samples/**" />.

Implicit global usings

With <ImplicitUsings>enable</ImplicitUsings> (in every dotnet new template since .NET 6, and in Ledgerline's Directory.Build.props), the SDK generates a file of global using directives in obj/:

cat obj/Debug/net10.0/Ledgerline.GlobalUsings.g.cs
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Net.Http;
global using System.Threading;
global using System.Threading.Tasks;

Microsoft.NET.Sdk.Web and .Worker add their hosting namespaces; WinForms adds System.Drawing and System.Windows.Forms; WPF removes System.IO and System.Net.Http to avoid clashes. Add or remove namespaces with Using items — <Using Include="Ledgerline" />, <Using Remove="System.Net.Http" />, or an alias <Using Include="System.Text.Json.JsonSerializer" Alias="Json" />. Since the .NET 8 SDK, System.Net.Http is no longer added when targeting .NET Framework.

Implicit package and framework references

The project does not reference the framework explicitly either. For net5.0 and later, the SDK adds a FrameworkReference to Microsoft.NETCore.App (plus Microsoft.AspNetCore.App for the Web SDK). For netstandard1.0--netstandard2.0 it adds an implicit PackageReference to NETStandard.Library, marked IsImplicitlyDefined, so it cannot be updated or removed by hand. Querying Ledgerline for its netstandard2.0 target shows it next to the two real references, whose versions come from Directory.Packages.props (Central Package Management), not from the project:

dotnet msbuild -p:TargetFramework=netstandard2.0 -getItem:PackageReference
NETStandard.Library 2.0.3 true
Microsoft.Bcl.HashCode None
System.Text.Json None

(columns: identity, Version metadata, IsImplicitlyDefined)

The complete Ledgerline library project

Everything shared between projects lives in the repository’s Directory.Build.props (language version, Nullable, ImplicitUsings, TreatWarningsAsErrors, authors, licence, repository URL) and Directory.Packages.props (versions), so the library project is short and states only what is specific to it:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFrameworks>net10.0;net8.0;netstandard2.0</TargetFrameworks>
    <RootNamespace>Ledgerline</RootNamespace>
    <IsPackable>true</IsPackable>
    <PackageId>Ledgerline</PackageId>
    <Version>1.0.0</Version>
    <Description>Double-entry bookkeeping ledgers: read journal CSV files, check that every entry balances and export a trial balance as JSON.</Description>
    <PackageTags>ledger;accounting;double-entry;trial-balance;csv</PackageTags>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <EnablePackageValidation>true</EnablePackageValidation>
  </PropertyGroup>

  <ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
    <PackageReference Include="Microsoft.Bcl.HashCode" />
    <PackageReference Include="System.Text.Json" />
  </ItemGroup>

</Project>
Element Why

TargetFrameworks (plural)

Multi-targeting: one inner build per TFM (Target Frameworks and Runtime Identifiers).

IsPackable, PackageId, Version, Description, PackageTags

Package metadata for dotnet pack; IsPackable is false by default in Directory.Build.props so only libraries and tools pack (Package Metadata).

GenerateDocumentationFile

Writes Ledgerline.xml from the XML doc comments and enables the missing-comment warnings (see Coding Conventions and Documentation).

EnablePackageValidation

Checks the package for API compatibility between TFMs and versions (Package Validation).

The conditional ItemGroup

Polyfill packages only for netstandard2.0; the other TFMs get the APIs from the framework.

Legacy (non-SDK) projects

Before .NET Core, a C# project listed every file and imported the common targets explicitly; Visual Studio rewrote it on every change. Such projects are still met in .NET Framework code bases, typically together with packages.config. A fragment of one (not run here: it needs the .NET Framework targeting packs that Visual Studio installs):

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
  <PropertyGroup>
    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
    <ProjectGuid>{6A1F5B0E-0000-4C1D-9C2E-000000000000}</ProjectGuid>
    <OutputType>Library</OutputType>
    <RootNamespace>Ledgerline</RootNamespace>
    <TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
  </PropertyGroup>
  <ItemGroup>
    <Reference Include="System" />
    <Reference Include="System.Core" />
  </ItemGroup>
  <ItemGroup>
    <Compile Include="Ledger.cs" />
    <Compile Include="LedgerReader.cs" />
    <Compile Include="Properties\AssemblyInfo.cs" />
  </ItemGroup>
  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

The differences that matter when converting one: the explicit Compile list (delete it, or NETSDK1022 follows), Properties\AssemblyInfo.cs (the SDK generates the same attributes — delete the file or set GenerateAssemblyInfo to false, otherwise the compiler reports duplicate attributes), packages.config (migrate to PackageReference first, see nuget.exe, Visual Studio and packages.config) and TargetFrameworkVersion v4.8, which becomes <TargetFramework>net48</TargetFramework>. Choosing and Combining the Tools covers the migration path.

Common mistakes

  • Re-adding files the SDK already includes — NETSDK1022. Use Update/Remove, not Include.

  • A project inside another project’s folder — its sources are compiled twice.

  • Editing obj/*.g.cs files — they are regenerated on every build; set properties instead (AssemblyTitle, Using items).

  • Giving a NuGet-delivered SDK no version — MSB4236; put versions in global.json msbuild-sdks.

  • Keeping AssemblyInfo.cs after converting — duplicate attribute errors (CS0579).

  • Overriding an SDK target in the project body — it is ignored because Sdk.targets is imported later and redefines it; use explicit imports or an AfterTargets hook.

Release notes

  • .NET 6: implicit global usings (ImplicitUsings).

  • .NET 8: System.Net.Http no longer an implicit using for .NET Framework targets.

  • .NET 10: .slnx becomes the default solution format for dotnet new sln (Solutions and .slnx).