You've just pulled up a terminal, made a quick change to a service, and now you need to test it with a specific environment variable. In .NET 10, that meant either exporting a shell variable, editing launchSettings.json, or reaching for a .env file loader. In .NET 11 Preview 3, you type dotnet run -e MY_VAR=value and move on with your day.

That's the theme running through the SDK improvements in this preview: small, well-placed quality-of-life changes that remove friction from the daily development loop. Multi-file support for file-based apps, inline environment variables, smarter dotnet watch behaviour, and CLI-editable solution filters all landed in the same release. None of them is a headline-grabbing language feature, but collectively they reshape how you interact with the CLI.

Inline environment variables with dotnet run -e

The new -e flag on dotnet run lets you pass environment variables directly from the command line:

terminal
dotnet run -e ASPNETCORE_ENVIRONMENT=Development -e LOG_LEVEL=Debug

Variables passed this way take precedence over ambient environment variables, System.CommandLine env directives, and any environmentVariables block in the active launch profile. That precedence order matters — it means you can override a launch profile's defaults without touching the file.

Under the hood, these values are also available to MSBuild as RuntimeEnvironmentVariable items. That opens the door to conditional build logic based on runtime intent, though most developers will simply use it to toggle configuration during local testing.

terminal
# Quick test against a staging database
dotnet run -e ConnectionStrings__Default="Server=staging;Database=Orders;..." -e ASPNETCORE_ENVIRONMENT=Staging

// TIP

The -e flag works with file-based apps too. dotnet run app.cs -e MY_VAR=value passes the variable through without any project file ceremony.

Before this feature, the common workarounds were:

The -e flag is the cleanest solution for ad-hoc overrides, and it's one of those features that, once you've used it, you'll wonder how you managed without.

File-based apps go multi-file with #:include

File-based apps — the dotnet run app.cs workflow introduced in .NET 10 — were intentionally limited to a single file in their first release. The team wanted to nail the single-file experience before tackling the complexity of multiple files. With Preview 3, that limitation is gone.

The new #:include directive lets you pull in additional source files:

app.cs
#:include helpers.cs
#:include models/customer.cs

var customer = new Customer("Acme Corp", "GB");
Console.WriteLine(Helpers.FormatSummary(customer));
models/customer.cs
public record Customer(string Name, string CountryCode);
helpers.cs
public static class Helpers
{
    public static string FormatSummary(Customer c) =>
        $"{c.Name} ({c.CountryCode})";
}

Run it as before:

terminal
dotnet run app.cs

The directive accepts relative paths from the file containing it. You can include files from subdirectories, and each included file can itself contain #: directives like #:package or #:property.

Why this matters

The single-file constraint pushed developers toward an awkward choice: either cram everything into one file or graduate to a full .csproj project. Multi-file support fills the gap between throwaway scripts and proper solutions. Realistic use cases include:

You still get native AOT by default, the #:package directive for NuGet references, and the ability to convert to a full project later with dotnet project convert app.cs.

// WARNING

File-based apps still respect Directory.Build.props and Directory.Build.targets from parent directories. If your file-based app lives inside a repository with these files, their settings will affect your build. Keep file-based apps in isolated directories to avoid surprises.

Combining with launch profiles

File-based apps support a flat launch settings format using [appname].run.json:

app.run.json
{
  "profiles": {
    "dev": {
      "commandName": "Project",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Run with a specific profile:

terminal
dotnet run app.cs --launch-profile dev

When multiple file-based apps share a directory, each gets its own .run.json file — foo.run.json for foo.cs, bar.run.json for bar.cs.

Solution filters from the CLI

Large repositories often contain dozens of projects, but individual developers typically work on a subset. Solution filters (.slnf files) solve this by defining a view over a solution that only loads specific projects. Until now, creating and editing them required Visual Studio or hand-editing JSON.

Preview 3 brings solution filter management to the dotnet sln command:

terminal
# Create a new solution filter
dotnet new slnf --name MyApp.slnf

# Add projects to the filter
dotnet sln MyApp.slnf add src/Api/Api.csproj
dotnet sln MyApp.slnf add src/Core/Core.csproj

# List projects in the filter
dotnet sln MyApp.slnf list

# Remove a project
dotnet sln MyApp.slnf remove src/Core/Core.csproj

The commands mirror the existing dotnet sln operations you already know for .sln files. This is particularly useful in CI/CD pipelines where you want to build or test a subset of a solution without maintaining a separate .sln file.

// NOTE

Solution filters reference an underlying .sln file. The dotnet sln commands operate on the filter's project list, not the parent solution's.

A smarter dotnet watch

dotnet watch has been the backbone of the .NET inner development loop for years, but it had a few rough edges that Preview 3 addresses.

Crash recovery

Previously, when your application crashed during a dotnet watch session, you'd see the error and then... nothing. You had to manually restart the watcher or save a file to trigger a rebuild. Now, dotnet watch automatically relaunches the application on the next relevant file change after a crash.

This sounds minor, but it changes the workflow significantly. You see the crash, fix the code, save the file, and the app restarts. No context switch to the terminal to press Ctrl+C and re-run the command.

Aspire integration

dotnet watch can now orchestrate Aspire AppHost projects. This was a notable gap — developers using Aspire for local development had to choose between hot reload and the Aspire dashboard. With this integration, you get both: file changes trigger hot reload across the full Aspire application model, including dependent services.

terminal
dotnet watch --project src/AppHost/AppHost.csproj

Windows desktop improvements

Ctrl+C handling is improved for Windows Forms and WPF applications. Previously, dotnet watch could leave orphaned processes when terminating desktop apps. The shutdown sequence is now cleaner, properly signalling the application before forcefully terminating it.

Smaller SDK installers

This one won't change your daily workflow, but it's worth noting: the .NET 11 SDK installer is meaningfully smaller on Linux and macOS. The team found that 35% of the SDK directory consisted of duplicate files — 816 files totalling 140 MB on Linux x64. By replacing duplicates with symbolic links, the installers shrink considerably:

Platform Artifact .NET 10 .NET 11 Preview 2 Reduction
linux-x64 tarball 230 MB 189 MB 17.8%
linux-x64 deb 164 MB 122 MB 25.6%
linux-x64 rpm 165 MB 122 MB 26.0%
linux-x64 containers varies varies 8-17%

Windows deduplication is planned for a future preview. For container-based deployments and CI runners where the SDK is downloaded frequently, this is a meaningful improvement.

Improved CA1873 diagnostics

The CA1873 analyser (which warns about potentially expensive arguments in logging calls) received two improvements that make it more practical to leave enabled.

Reduced false positives. Property accesses, GetType(), GetHashCode(), and GetTimestamp() calls are no longer flagged. The analyser now only fires for Information-level logging and below by default, since warning, error, and critical paths are rarely hot.

Specific diagnostic messages. Instead of a generic "evaluation of this argument may be expensive" message, the diagnostic now tells you why it flagged the call:

text
// Before
warning CA1873: Evaluation of this argument may be expensive and unnecessary if logging is disabled

// After  
warning CA1873: Evaluation of this argument may be expensive and unnecessary if logging is disabled (method invocation)

The nine specific reasons include method invocation, object creation, array creation, boxing conversion, string interpolation, collection expression, anonymous object creation, await expression, and with expression. This specificity makes it much easier to decide which warnings warrant attention.

Other CLI changes worth knowing

A few smaller improvements round out the SDK update:

Common pitfalls

Mixing -e with launch profiles. Variables passed via -e override launch profile values, which is usually what you want. But if you're debugging a "why is my config wrong" issue, remember to check both sources.

File-based app cache staleness. The build cache for file-based apps is based on source content and directives, but changes to Directory.Build.props in parent directories don't always invalidate the cache. If your file-based app behaves unexpectedly after changing implicit build files, run dotnet clean file-based-apps to clear the cache.

Solution filter project paths. Solution filter entries are relative to the .sln file, not the .slnf file. If your filter lives in a different directory from the solution, the paths in the filter still reference the solution's location.

Aspire watch and port conflicts. When using dotnet watch with Aspire, ensure your AppHost isn't configured with fixed ports that conflict with the restarted instances. Dashboard and service ports should use dynamic allocation during development.

Summary