File-Based Apps: Running a Single .cs File (.NET 10)
Objective
The .NET 10 SDK can build and run a single .cs file with no .csproj:
dotnet run app.cs. Configuration that used to live in the project file moves
to #: directives at the top of the file, and the SDK generates a project in
memory to build it. That makes C# usable for scripts and small tools, but the
generated project is not a neutral one: it targets Native AOT by default, and
that default changes how the app behaves even under dotnet run. This concept
shows what the SDK generates, which directives exist, the AOT trap, and when to
convert the file into a real project. Everything below was run on SDK 10.0.100.
Use Cases
- Replacing a Bash or Python script in a .NET repository with C#, using the same libraries the application uses.
- Trying a NuGet package or an API in seconds, without creating a project.
- Shipping a small command-line tool as one native executable.
- Starting a prototype as one file and converting it to a project when it grows.
Deep Dive
Running a file, with packages and properties
csharp#:package [email protected]
#:property PublishAot=false
using Humanizer;
Console.WriteLine($"Args: {string.Join(", ", args)}");
Console.WriteLine(TimeSpan.FromMinutes(90).Humanize(precision: 2));
Console.WriteLine("file based app".Pascalize());text$ dotnet run hello.cs -- one two Args: one, two 1 hour, 30 minutes FileBasedApp
Arguments for the app go after --. dotnet hello.cs a b is a shorter form,
and echo 'Console.WriteLine("hello from stdin");' | dotnet run - reads the
program from standard input. The directives are:
#:package Name@Versionadds aPackageReference.#:property Name=Valuesets an MSBuild property.#:sdk Namereplaces the defaultMicrosoft.NET.Sdk.#:project pathreferences a project (documented; not exercised here).
Directives must come before any code; one placed after a statement fails with
CS9297: '#:' directives cannot be after first token in file. #:sdk Microsoft.NET.Sdk.Web is enough
for a minimal API:
csharp#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
var app = WebApplication.Create(args);
app.MapGet("/ping", () => new { pong = true, at = "file-based app" });
app.Run("http://localhost:5240");
// curl http://localhost:5240/ping -> {"pong":true,"at":"file-based app"}The virtual project and the build cache
The SDK synthesizes a project from the file and builds it like any other. The
easiest way to see that project is to ask for it: dotnet project convert hello.cs writes a folder hello/ with a copy of the code, directives removed,
and this .csproj (the original file is left untouched):
plaintext<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <PackAsTool>true</PackAsTool> <UserSecretsId>hello-fc9e4937...</UserSecretsId> <PublishAot>false</PublishAot> </PropertyGroup> <ItemGroup> <PackageReference Include="Humanizer" Version="2.14.1" /> </ItemGroup> </Project>
The same command on a file without #:property PublishAot=false produces
<PublishAot>true</PublishAot>: that is the default. PackAsTool and a
UserSecretsId derived from the file are defaults too.
Build output does not land next to the file. On this macOS machine it went to
~/Library/Application Support/dotnet/runfile/hello-<hash>/bin/debug/ (the
documentation describes a folder under the system temp directory, so the
location varies by platform). The SDK skips the build when nothing changed: the
first run after an edit took 0.79 s here and the next one 0.15 s.
The AOT default reaches dotnet run
dotnet run app.cs still uses the JIT; it does not compile natively. But with
PublishAot=true, the build writes the Native AOT feature switches into the
generated runtimeconfig.json, among them:
plaintext"System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": false, "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": false, "System.Diagnostics.Tracing.EventSource.IsSupported": false, "System.Reflection.Metadata.MetadataUpdater.IsSupported": false
So code that generates code at run time fails during development, exactly as it
would in a native binary. Three runs on SDK 10.0.100 without PublishAot=false:
text// Dapper 2.1.66, db.Query<Row>("select 1 as Id, 'dapper' as Name") IsDynamicCodeSupported: False Unhandled exception. System.PlatformNotSupportedException: Dynamic code generation is not supported on this platform. at System.Reflection.Emit.DynamicMethod..ctor(...) // EF Core 10.0.0 with SQLite, first use of the DbContext Unhandled exception. System.InvalidOperationException: Model building is not supported when publishing with NativeAOT. Use a compiled model. // JsonSerializer.Serialize(new Point(1, 2)) warning IL3050: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(...)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Unhandled exception. System.InvalidOperationException: Reflection-based serialization has been disabled for this application.
With #:property PublishAot=false (or dotnet run app.cs -p:PublishAot=false)
the same three programs print Dapper: 1 dapper, EF Core: 1 product(s) and
{"X":1,"Y":2}. The build warnings IL2026 and IL3050 are the early signal:
when they appear in a file-based app, the run will probably hit a feature switch.
Shebang: a C# file as an executable
On Unix, a shebang line and the executable bit make the file a command:
csharp#!/usr/bin/env -S dotnet --
// Counts non-empty lines of each file passed as argument.
foreach (var path in args)
{
var lines = File.ReadLines(path).Count(l => l.Trim().Length > 0);
Console.WriteLine($"{lines,6} {path}");
}text$ chmod +x count-lines.cs $ ./count-lines.cs count-lines.cs ../fba/hello.cs 8 count-lines.cs 6 ../fba/hello.cs
The -- in the shebang matters: it makes dotnet pass every argument to the
app. Running ./count-lines.cs --help delivered --help to the program (which
then tried to open a file with that name) instead of printing the CLI help. The
documentation asks for LF line endings and no BOM in such files.
Publishing and converting
dotnet publish count-lines.cs honors the AOT default and produces a native
executable in artifacts/count-lines/ next to the file: here a 1.4 MB arm64
Mach-O binary that runs without dotnet, built in under 5 seconds.
dotnet project convert is the exit door. Use it when the script needs a second
source file, tests, or a place in a solution.
One file, and outside project folders
In SDK 10.0.100 the app is exactly one file. A class in a sibling greeter.cs
is not compiled:
textapp.cs(1,19): error CS0103: The name 'Greeter' does not exist in the current context
and #:include greeter.cs fails with error: Unrecognized directive 'include'.
The current documentation describes #:include for SDK 10.0.300 and later,
which was not available to test here.
The file also inherits its surroundings: the documentation lists
Directory.Build.props, Directory.Packages.props, nuget.config and
global.json from parent folders. A file placed in a folder that has a
.csproj is worse: dotnet run side.cs built the project instead, which
globbed side.cs into it and failed with CS8802: Only one compilation unit can have top-level statements. Keep scripts in their own folder.
Trade-offs
- No project file means less ceremony and less visibility. Defaults such as
PublishAot=trueandPackAsTool=trueare invisible until you convert the file or read the generatedruntimeconfig.json. - The AOT default protects the publish and surprises the script. It keeps a
file-based app publishable as a native binary, but reflection-heavy libraries
fail under plain
dotnet run.csharp#:property PublishAot=false // first line to add before using Dapper, EF Core or reflection JSON - The build cache is fast but keyed on the file. Changes to inherited build
files can be missed; the documentation recommends
dotnet clean app.cswhen a build looks stale. - One file is a hard limit in SDK 10.0.100. Shared helpers mean copying code or converting to a project; splitting needs a newer SDK.
- A shebang ties the script to an installed SDK. The target machine needs
.NET 10 on
PATH; for machines without it, publish the native binary instead.