The biggest complaint about file-based apps in .NET 10 was always the same: one file. You could reference NuGet packages, switch SDKs, set MSBuild properties, even publish to native AOT -- but the moment your script grew past a few hundred lines and you wanted to pull a helper class into its own file, the answer was dotnet project convert and back to .csproj land. The single-file boundary was a hard wall, and it made file-based apps feel like a toy for anything beyond a quick prototype.

.NET 11 Preview 3 removes that wall. The new #:include directive lets you pull additional source files into your file-based app's compilation, and #:exclude lets you remove files you don't want. Your entry-point file stays the entry point, but it no longer has to contain everything. The feature shipped on April 15, 2026, and as of SDK PR #53775, both directives are available by default -- no feature flags required.

The syntax

The #:include directive takes a file path or glob pattern and adds matching files to the compilation:

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

var customer = new Customer("Acme Corp", "GB");
Console.WriteLine(Formatting.FormatInvoiceHeader(customer));
helpers.cs
public static class Formatting
{
    public static string FormatInvoiceHeader(Customer customer)
        => $"INVOICE — {customer.Name} ({customer.Region})";
}
models/customer.cs
public record Customer(string Name, string Region);

Run it with dotnet run app.cs exactly as before. The SDK picks up helpers.cs and models/customer.cs, adds them to the virtual project as <Compile> items, and compiles everything together. The entry point remains app.cs -- included files provide types and members, not their own Main methods or top-level statements.

Glob patterns

You are not limited to naming files one at a time. The #:include directive supports standard glob patterns:

app.cs
#:include *.cs
#:include models/**/*.cs

The * wildcard matches any filename in the current directory. The ** pattern recurses through subdirectories. This means a single #:include **/*.cs will pull in every C# file below your entry point's directory.

For a utility script with a handful of helpers, explicit paths are clearer. For a larger script directory with an established structure, globs keep the directive list from growing stale every time you add a file.

The #:exclude directive

Where #:include adds files, #:exclude removes them. This is useful when a glob pattern is too broad:

app.cs
#:include **/*.cs
#:exclude tests/**/*.cs
#:exclude benchmarks/*.cs

Under the hood, #:exclude generates an MSBuild Remove="{pattern}" element. It only makes sense in combination with #:include -- you cannot exclude files that were never included in the first place.

This is not a C preprocessor #include

If you have worked with C or C++, the #:include syntax might trigger some assumptions. Set them aside. The C preprocessor's #include performs textual substitution -- it literally copies the contents of one file into another before compilation. The .NET #:include directive does nothing of the sort.

What it actually does is add files to the MSBuild compilation unit. Each included file is compiled as a separate source file, just as if you had listed it in a .csproj under <Compile Include="helpers.cs" />. The files share a compilation context -- they can reference each other's types and members -- but they are not stitched together textually. There is no concept of include order, no risk of symbol redefinition from paste collisions, and no macro expansion.

This distinction matters because it means included files are proper C# source files. They can declare namespaces, define classes, use their own using directives, and contain any valid C# -- everything except top-level statements, which are reserved for the entry-point file.

How file types map to item types

The #:include directive is not limited to .cs files. The SDK maps file extensions to MSBuild item types:

Extension Item type
.cs Compile
.resx EmbeddedResource
.json None
.razor Content

This means you can include resource files and configuration alongside your code:

app.cs
#:sdk Microsoft.NET.Sdk.Web
#:include services/*.cs
#:include appsettings.json

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/config", (IConfiguration config) =>
    config.GetValue<string>("AppName"));

app.Run();

A practical example: a multi-file CLI tool

Here is a more realistic scenario. You are building a quick CLI tool that processes log files, extracts error patterns, and writes a summary report. In .NET 10, this would have been squeezed into a single file or promoted to a full project. With #:include, you can organise it naturally:

analyse-logs.cs
#:package Spectre.Console@0.49.1
#:include parsing/*.cs
#:include reporting.cs

using Spectre.Console;

if (args.Length == 0)
{
    AnsiConsole.MarkupLine("[red]Usage: dotnet run analyse-logs.cs <log-directory>[/]");
    return 1;
}

var logDirectory = args[0];
var entries = LogParser.ParseDirectory(logDirectory);
var summary = ErrorSummary.Build(entries);

ReportWriter.WriteToConsole(summary);
return 0;
parsing/log-parser.cs
public static class LogParser
{
    public static IReadOnlyList<LogEntry> ParseDirectory(string path)
    {
        var entries = new List<LogEntry>();

        foreach (var file in Directory.EnumerateFiles(path, "*.log"))
        {
            foreach (var line in File.ReadLines(file))
            {
                if (LogEntry.TryParse(line, out var entry))
                    entries.Add(entry);
            }
        }

        return entries;
    }
}
parsing/log-entry.cs
using System.Text.RegularExpressions;

public partial record LogEntry(DateTimeOffset Timestamp, string Level, string Message)
{
    private static readonly Regex Pattern = LinePattern();

    public static bool TryParse(string line, out LogEntry? entry)
    {
        entry = null;
        var match = Pattern.Match(line);
        if (!match.Success) return false;

        entry = new LogEntry(
            DateTimeOffset.Parse(match.Groups["ts"].Value),
            match.Groups["level"].Value,
            match.Groups["msg"].Value);
        return true;
    }

    [GeneratedRegex(@"^(?<ts>\S+)\s+\[(?<level>\w+)]\s+(?<msg>.+)$")]
    private static partial Regex LinePattern();
}
parsing/error-summary.cs
public record ErrorSummary(
    int TotalEntries,
    int ErrorCount,
    IReadOnlyDictionary<string, int> TopMessages)
{
    public static ErrorSummary Build(IReadOnlyList<LogEntry> entries)
    {
        var errors = entries.Where(e => e.Level == "ERR").ToList();

        var topMessages = errors
            .GroupBy(e => e.Message)
            .OrderByDescending(g => g.Count())
            .Take(10)
            .ToDictionary(g => g.Key, g => g.Count());

        return new ErrorSummary(entries.Count, errors.Count, topMessages);
    }
}
reporting.cs
using Spectre.Console;

public static class ReportWriter
{
    public static void WriteToConsole(ErrorSummary summary)
    {
        AnsiConsole.MarkupLine($"[bold]Total entries:[/] {summary.TotalEntries}");
        AnsiConsole.MarkupLine($"[bold red]Errors:[/] {summary.ErrorCount}");
        AnsiConsole.WriteLine();

        var table = new Table()
            .AddColumn("Message")
            .AddColumn("Count");

        foreach (var (message, count) in summary.TopMessages)
            table.AddRow(message.EscapeMarkup(), count.ToString());

        AnsiConsole.Write(table);
    }
}

Five files, zero .csproj, and you run it with dotnet run analyse-logs.cs /var/log/myapp. When it outgrows this structure, dotnet project convert analyse-logs.cs will still produce a proper project with all the files and references intact.

Passing environment variables inline

Preview 3 also added the -e flag for passing environment variables directly on the command line:

terminal
dotnet run app.cs -e LOG_LEVEL=Debug -e REGION=eu-west-1

This sidesteps the need for shell-level export statements or launch profile configurations when you just want to toggle a setting for a single run. Combined with #:include, it makes file-based apps practical for deployment scripts and automation tasks that previously demanded a full project setup.

Transitive directives: not yet ready

You might wonder whether included files can declare their own #:package or #:property directives. The answer is: technically yes, but not by default. Processing directives in non-entry-point files is gated behind a feature flag:

Example.cs
#:property ExperimentalFileBasedProgramEnableTransitiveDirectives=true

The reason for the gate is IDE tooling. Roslyn needs to determine which file is the entry point when multiple files contain directives, and the heuristics for that are not fully baked. The SDK team removed the feature flags for #:include and #:exclude themselves in April 2026, but kept this one until editor support catches up.

For now, keep all your directives in the entry-point file. This is a reasonable constraint -- it means there is exactly one place to look for package references and build configuration, which is arguably better for readability anyway.

// TIP

If you find yourself wanting transitive directives because an included file needs a specific NuGet package, add the #:package directive to your entry-point file instead. The package is available to all files in the compilation regardless of where the directive lives.

IDE support

Roslyn has already shipped an IncludeAppDirectiveCompletionProvider (merged in PR #82625) that offers IntelliSense completions when you type #:include. It suggests patterns like *.cs and **/*.cs based on the files in your directory.

That said, IDE support was still catching up at the time of Preview 3's release. VS Code with C# Dev Kit may not have full recognition of the #:include directive depending on your extension version. The CLI experience is solid -- if your editor is lagging behind, dotnet run and dotnet watch will work correctly regardless.

// NOTE

JetBrains Rider 2026.1 shipped support for running file-based C# programs. Check Rider's release notes to confirm #:include directive support in your version.

Common pitfalls

Including the entry-point file in a glob. If your entry-point file is app.cs and you write #:include *.cs, the SDK is smart enough to skip the entry-point file itself. But being explicit is clearer -- prefer #:include helpers.cs or use #:exclude to document the intent:

Example.cs
#:include *.cs
#:exclude app.cs

Duplicate directives. The SDK rejects duplicate directives with the same type and name (case-insensitive). If helpers.cs appears in both an explicit #:include helpers.cs and a glob #:include *.cs, you will get an error. Either use the glob alone or list files individually -- do not mix both for the same file.

Concurrent execution. Running multiple instances of the same file-based app simultaneously can cause file access errors because they compete for the same build cache directory. Pre-compile with dotnet build app.cs and then run with --no-build to avoid contention:

terminal
dotnet build app.cs
dotnet run app.cs --no-build &
dotnet run app.cs --no-build &

Assuming included files can have top-level statements. Only the entry-point file supports top-level statements. Included files must define their code inside types -- classes, records, structs, enums. If you try to put bare Console.WriteLine(...) in an included file, the compiler will tell you that only one compilation unit can have top-level statements.

Stale build cache with structural changes. The build cache does not always detect changes to implicit build files like Directory.Build.props. If you move files between directories or modify the directory structure, you may need to clear the cache with dotnet clean file-based-apps to pick up the changes.

The bigger picture

The #:include directive closes the most significant gap in the file-based apps story. In .NET 10, the feature was a compelling demo but had a hard ceiling -- the moment your script needed a second file, you reached for dotnet project convert. That ceiling made it difficult to recommend file-based apps for anything beyond throwaway prototypes.

With #:include, the feature covers a much wider range of use cases: multi-file CLI tools, structured utility scripts, lightweight services with separated concerns, and exploratory code that might eventually graduate to a full project. The upgrade path remains clean -- dotnet project convert still works, and it now handles the included files correctly.

The remaining rough edge is transitive directives. Once included files can declare their own packages and properties without a feature flag, file-based apps will be a genuinely self-contained alternative to .csproj for small-to-medium tools. That is likely coming in a later .NET 11 preview once the Roslyn tooling catches up.

Summary