Build items for files: Compile, None, Content and EmbeddedResource, CopyToOutputDirectory and CopyToPublishDirectory, embedded resource naming and LogicalName, reading with GetManifestResourceStream, .resx localisation, and when to embed versus copy.
SDK-style projects classify files into item types. Compile is C# source, picked up automatically. None is everything else, and it does nothing unless you set metadata such as CopyToOutputDirectory="PreserveNewest" to copy the file next to the app. Web projects use Content for files that get published, like wwwroot. EmbeddedResource compiles the file into the assembly. You read it with Assembly.GetManifestResourceStream(name). The default name is the root namespace plus the folder path joined with dots, and LogicalName lets you choose a stable name. .resx files are embedded resources used for localisation. I embed small, read-only assets that must travel with the code, such as SQL scripts, templates or schemas. Files that operators might change, or large ones, I copy to the output instead.
"SDK-style projects glob files by default and classify each one as an item type.
Compileis C# source, picked up from**/*.cs.Noneis everything else, and it does nothing unless you add metadata:CopyToOutputDirectoryset toPreserveNewestcopies the file next to the app, andCopyToPublishDirectorycontrols publish. Because the file is already globbed, I useUpdaterather thanIncludeto set that metadata. Web projects useContentfor things likewwwrootthat get published.
EmbeddedResourcecompiles the file into the assembly, and I read it withAssembly.GetManifestResourceStream. The default name is the root namespace plus the folder path joined with dots, and the mistake I see is relying on that: rename a folder and the lookup silently returns null. I set an explicitLogicalNameinstead, and when a stream is null I printGetManifestResourceNames()to see the real names..resxfiles are embedded resources too, used for localisation throughResourceManagerorIStringLocalizer.Embed versus copy is really a deployment decision. I embed small, read-only assets that must travel with the code, like SQL scripts, templates or schemas, so they can't go missing. Large files, or ones operators might change, I copy and locate with
AppContext.BaseDirectory, which also works for single-file apps. Either way I never put secrets in them, I read once and cache, and I add a test that loads every resource the code depends on."
| Item | Default in SDK projects | Result |
|---|---|---|
Compile |
**/*.cs |
Compiled into the assembly |
EmbeddedResource |
**/*.resx |
Stored inside the assembly as a manifest resource |
None |
Other files in the folder | Ignored by the build unless metadata says otherwise |
Content |
Web SDK: wwwroot/**, appsettings*.json and similar |
Copied to output and publish |
<ItemGroup>
<None Update="data/postcodes.csv" CopyToOutputDirectory="PreserveNewest" />
<None Update="tools/**" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
Update changes metadata on a file that's already globbed (Include would add a duplicate). PreserveNewest copies only when the source is newer; Always copies on every build and breaks incremental builds, so avoid it.
<ItemGroup>
<EmbeddedResource Include="Sql/*.sql" />
<EmbeddedResource Include="Templates/receipt.html" LogicalName="Orders.Templates.Receipt" />
</ItemGroup>
Naming: without LogicalName, the manifest name is RootNamespace + relative folder path + file name, with path separators turned into dots: Sql/CreateOrders.sql in a project with root namespace Orders.Data becomes Orders.Data.Sql.CreateOrders.sql. Renaming a folder or the namespace changes the name, so explicit LogicalNames are safer for anything code looks up by name.
Reading:
await using var stream = typeof(ReceiptRenderer).Assembly
.GetManifestResourceStream("Orders.Templates.Receipt")
?? throw new InvalidOperationException("Receipt template not embedded");
Assembly.GetManifestResourceNames() lists every embedded resource, which is the fastest way to debug a null stream.
Strings.resx plus Strings.en-AU.resx are embedded resources. The build compiles culture-specific ones into satellite assemblies (en-AU/Orders.resources.dll), and ResourceManager or ASP.NET Core's IStringLocalizer picks the right one by culture.
| Embed | Copy to output |
|---|---|
| Small, read-only, versioned with the code (SQL scripts, JSON schemas, email templates, test fixtures) | Large files, or files operators may change (configuration, certificates, data files) |
| Must not be missing or edited in production | Should be replaceable without rebuilding |
A reporting service read SQL from .sql files copied to the output folder. A deployment script that copied only DLLs and appsettings.json left the SQL behind, and reports failed in production. Embedding the scripts with explicit LogicalNames made the assembly self-contained, and a unit test that loads every expected resource name now catches renames.
PreserveNewest over Always.Checks practical knowledge of how non-code files get into a build.
Green flags: knows the item types and copy metadata; explains manifest naming and LogicalName; reads resources correctly; can justify embed versus copy.
Red flags: can't explain a null resource stream; uses Always everywhere; embeds secrets.
project folder
|-- Program.cs -> Compile -> csc -> Orders.dll (IL)
|-- Sql/CreateOrders.sql -> EmbeddedResource -> csc /resource -> inside Orders.dll
|-- Strings.resx -> EmbeddedResource -> GenerateResource -> Orders.dll (neutral culture)
|-- Strings.en-AU.resx -> EmbeddedResource -> GenerateResource -> en-AU/Orders.resources.dll (satellite)
|-- data/postcodes.csv -> None + CopyToOutputDirectory -> bin/.../data/postcodes.csv
|-- wwwroot/site.css -> Content (Web SDK) -> bin/... and publish/wwwroot/
`-- notes.md -> None (no metadata) -> nowhere
Run time:
typeof(X).Assembly.GetManifestResourceStream("Orders.Data.Sql.CreateOrders.sql")
File.OpenRead(Path.Combine(AppContext.BaseDirectory, "data", "postcodes.csv"))
Every file in the project folder becomes some item, because SDK-style projects glob by default. The item type decides what happens to it.
| Item | Default include | Build result | Key metadata |
|---|---|---|---|
Compile |
**/*.cs |
Compiled into the assembly | Link (for files outside the folder) |
EmbeddedResource |
**/*.resx |
Stored inside the assembly | LogicalName, WithCulture, DependentUpon |
None |
Everything else | Nothing, unless told to copy | CopyToOutputDirectory, CopyToPublishDirectory, Link |
Content |
Web SDK: wwwroot/**, **/*.json, **/*.config, Razor files |
Copied to output and publish | Same copy metadata, plus ExcludeFromSingleFile |
| Value | Effect |
|---|---|
Never (default for None) |
Not copied |
PreserveNewest |
Copied when the source is newer than the destination |
Always |
Copied on every build; marks the project as never up to date in Visual Studio's fast up-to-date check |
CopyToOutputDirectory controls bin/; CopyToPublishDirectory controls publish/. Files copied to the output are normally published too.
<ItemGroup>
<None Include="..\..\shared\countries.json" Link="data\countries.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Link sets where the file appears in the project tree and in the output.
Files a project copies to its output are also copied into the output of projects that reference it. A test project referencing Orders.Reporting gets data/postcodes.csv too. That's usually what you want, but it can surprise you when two referenced projects copy files with the same relative path: the last one copied wins.
Without LogicalName, the manifest name is built like this:
RootNamespace + relative folder path (separators -> dots) + file name
Orders.Data . Sql . CreateOrders.sql
=> "Orders.Data.Sql.CreateOrders.sql"
| Detail | Effect |
|---|---|
| Folder names that aren't valid identifiers | Are adjusted (for example spaces become underscores), but the file name isn't |
Renaming a folder or RootNamespace |
Changes every default name, and lookups silently return null |
.resx next to a .cs file with a matching class |
Named after that class's namespace and name (the DependentUpon convention, on by default) |
LogicalName="Orders.Templates.Receipt" |
Exactly that name, regardless of folders |
LogicalName="Orders.Sql.%(Filename)" on a glob |
A stable naming scheme for a whole folder |
The fastest debugging step for a null stream:
foreach (var name in typeof(Program).Assembly.GetManifestResourceNames())
Console.WriteLine(name);
| API | Use for |
|---|---|
Assembly.GetManifestResourceStream(name) |
Any embedded file; returns null if the name doesn't match |
Assembly.GetManifestResourceNames() |
Listing and diagnostics |
ManifestEmbeddedFileProvider (package Microsoft.Extensions.FileProviders.Embedded, with GenerateEmbeddedFilesManifest=true) |
Treat embedded files as a folder tree, with the original paths; useful for templates and static files |
ResourceManager, IStringLocalizer<T> |
Localised strings from .resx |
The stream is a view over the loaded assembly image. The real cost is what you build from it, so read once and cache:
private static readonly Lazy<string> ReceiptTemplate = new(() =>
{
using var stream = typeof(ReceiptRenderer).Assembly.GetManifestResourceStream("Orders.Templates.Receipt")
?? throw new InvalidOperationException("Receipt template not embedded.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
});
Strings.resx -> neutral strings, inside Orders.dll
Strings.en-AU.resx -> en-AU/Orders.resources.dll
Strings.fr.resx -> fr/Orders.resources.dll
ResourceManager looks up: en-AU -> en -> neutral (inside the main assembly)
NeutralResourcesLanguage (for example en-AU) so lookups for the default culture don't search for a satellite that doesn't exist.<SatelliteResourceLanguages>en</SatelliteResourceLanguages> keeps only the ones you need, which shrinks the output and container image noticeably.IStringLocalizer<T> with AddLocalization() and request localisation middleware chooses the culture per request.| Publish shape | Embedded resources | Copied files |
|---|---|---|
| Framework-dependent folder | Work as normal | Next to the app; find them with AppContext.BaseDirectory |
| Single-file | Work as normal | Stay next to the executable unless IncludeAllContentForSelfExtract=true; Assembly.Location is empty for bundled assemblies |
| Trimmed / Native AOT | Kept with their assembly | Unaffected |
Use AppContext.BaseDirectory to locate copied files in every shape; Assembly.Location and Directory.GetCurrentDirectory() are wrong for single-file apps and for services started from another folder.
| Question | Embed | Copy |
|---|---|---|
| Must it ship with exactly this version of the code? | Yes: SQL scripts, schemas, templates, test fixtures | |
| Should operators change it without a rebuild? | Yes: data files, optional configuration | |
| Is it large (megabytes)? | Yes, or move it to blob storage | |
| Is it sensitive? | No, never | No: use Key Vault or a secret store |
| Is it localised text? | .resx |
CopyToOutputDirectory="Always" makes Visual Studio treat the project as always out of date, so it rebuilds on every F5.SatelliteResourceLanguages.Resource handling is stable. Watch the .NET 11 SDK notes for changes to single-file and AOT publishing defaults that affect copied files. Keep production on .NET 10 until you've tested an upgrade.
Sql/Hello.sql to a console project and mark it as EmbeddedResource.typeof(Program).Assembly.GetManifestResourceNames() and note the default name.Sql folder to Scripts and print the names again.LogicalName="Demo.Sql.%(Filename)", update the code, and rename the folder back; the name no longer changes.data/info.txt with CopyToOutputDirectory="PreserveNewest", build, and read it with AppContext.BaseDirectory.data/info.txt appears in the test output.-p:PublishSingleFile=true -r linux-x64 and compare Assembly.Location with AppContext.BaseDirectory.SatelliteResourceLanguages to en and count again.<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Orders.Reporting</RootNamespace>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Sql/**/*.sql" LogicalName="Orders.Reporting.Sql.%(Filename)" />
<None Update="data/postcodes.csv" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
using System.Reflection;
namespace Orders.Reporting;
public static class SqlScripts
{
private static readonly Assembly Assembly = typeof(SqlScripts).Assembly;
public static async Task<string> LoadAsync(string name, CancellationToken cancellationToken = default)
{
var resourceName = $"Orders.Reporting.Sql.{name}";
await using var stream = Assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException(
$"Embedded SQL '{resourceName}' not found. Available: {string.Join(", ", Assembly.GetManifestResourceNames())}");
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync(cancellationToken);
}
}
// A test that fails if a script is renamed or not embedded
public class SqlScriptsTests
{
[Theory]
[InlineData("MonthlyGstSummary")]
[InlineData("OutstandingInvoices")]
public async Task Script_is_embedded(string name) =>
Assert.False(string.IsNullOrWhiteSpace(await SqlScripts.LoadAsync(name)));
}
%(Filename) is item metadata without the extension, so Sql/MonthlyGstSummary.sql becomes Orders.Reporting.Sql.MonthlyGstSummary.
Mistake: using Include on a file that is already globbed.
<None Include="data/postcodes.csv" CopyToOutputDirectory="..." /> adds a second item instead of changing the first, causing duplicate-item errors or files copied twice.
Instead: use Update to change metadata on globbed files, and Include only for files outside the default globs.
Mistake: guessing the manifest resource name.
GetManifestResourceStream returns null when the name doesn't match exactly, and the failure appears later as a NullReferenceException.
Instead: print GetManifestResourceNames() to see the real names, and throw a clear exception when a lookup returns null.
Mistake: relying on default resource names.
Renaming a folder or changing RootNamespace changes every default name, so lookups break silently after an innocent refactor.
Instead: set an explicit LogicalName (for example Orders.Sql.%(Filename) on a glob) and add a test that loads every resource the code needs.
Mistake: using CopyToOutputDirectory="Always".
The file is copied on every build and Visual Studio treats the project as never up to date, slowing every build.
Instead: use PreserveNewest unless you have a specific reason to copy every time.
Mistake: embedding secrets, keys or certificates. Anyone with the DLL can extract embedded resources in seconds with standard tools. Instead: load secrets at run time from Key Vault, managed identity or environment variables.
Mistake: embedding large files and reading them on every request. Assembly and image sizes grow, and each request allocates a large string or object graph. Instead: keep embedded assets small, read once and cache the result, and move large data to copied files or blob storage.
Mistake: finding copied files with Assembly.Location or the current directory.
Assembly.Location is empty for single-file apps, and the current directory depends on how the process was started (services often start in C:\Windows\System32 or /).
Instead: build paths from AppContext.BaseDirectory.
Mistake: forgetting that copied files flow to referencing projects. Two referenced projects that copy files with the same relative path overwrite each other in the consumer's output, and the app reads the wrong file. Instead: give copied files distinct paths per project, or embed assets that belong to one library.
Mistake: shipping every satellite language from packages.
Dozens of culture folders bloat the output and container image for languages the app never uses.
Instead: set SatelliteResourceLanguages to the cultures you support, for example en.
Mistake: relying on deployment scripts to copy loose files.
A script that copies only DLLs and appsettings.json leaves SQL files or templates behind, and the feature fails only in production.
Instead: embed assets the code can't run without, or deploy the whole publish folder, and add a start-up check that required files exist.
Choosing between embedding and copying is a deployment decision: embedded assets can't go missing or be edited, copied ones can be changed without a rebuild. A senior engineer makes that choice deliberately per asset, uses explicit resource names with a test that guards them, and keeps secrets out of both.
Size and performance. Embedded resources are part of the assembly image, so every one adds to DLL size, container image size and download time for every deployment, even if the code path that reads it never runs. Reading a resource returns a stream over the loaded image; the real cost is what you materialise from it, such as a large string or a parsed object graph. Seniors keep embedded assets small, read them once and cache the result (a static readonly or a lazily loaded singleton) instead of re-reading on every request, and move anything large (models, datasets, media) to copied files or blob storage. Copied files cost disk I/O on first read and need the same caching, plus a startup check that they exist.
For localisation they rely on .resx and satellite assemblies (or a translation service with a cache) rather than ad hoc file reading.
MSBuild is the build engine under dotnet build, Visual Studio and Rider, and a .csproj is an MSBuild file. Properties are named values such as $(Configuration). Items are lists with metadata, such as @(Compile) holding every .cs file. Targets are named steps, ordered with DependsOnTargets, BeforeTargets and AfterTargets. Each target runs tasks, the real units of work, such as Csc to compile or Copy. The build has two phases. Evaluation reads imports, properties and items. Execution runs the targets. Targets that declare Inputs and Outputs are skipped when nothing has changed, which is what makes builds incremental.
By default dotnet publish gives a framework-dependent folder that needs the .NET runtime on the server. Self-contained (--self-contained -r linux-x64) bundles the runtime, so nothing needs installing, but the output is bigger and you patch the runtime by redeploying. On top of that: single-file packs everything into one executable, trimming removes unused code, ReadyToRun precompiles to speed up start-up, and Native AOT compiles to a native binary with very fast start-up and low memory but no dynamic code generation. The SDK can also build a container image directly. I choose based on where it runs, start-up and memory needs, and whether the libraries are trim- and AOT-safe, and I measure the result.
An SDK-style project begins with <Project Sdk="Microsoft.NET.Sdk">. That one attribute pulls in all the standard build logic, so the file only holds what's specific to your project: the target framework, the output type, a few settings and its references. Source files are picked up automatically by globbing, so you don't list each .cs file. Packages are PackageReference items. Old-style .NET Framework project files listed every file, imported targets by hand and used packages.config. That made them long, noisy in merges and tied to Visual Studio on Windows.
Last updated 1 Nov 2026.