One of the more practical additions coming in .NET 10 preview is the ability to run a single C# file directly, without creating a project or a .csproj file first. Microsoft has not settled on one official name for it yet. You will see it called file-based programs in some places, runfile internally, and dotnet run app.cs in most public discussions, simply because that command is how you actually use it.
This is not a small syntax tweak. It changes the very first experience a new C# developer has with the language, and it opens up C# as a genuine option for utility scripts and one-off samples, areas that were traditionally left to bash, PowerShell, or Python. This article walks through what the feature does today, the directives it supports, who it is really built for, and what is explicitly staying out of scope.
What dotnet run app.cs actually does
Before .NET 10, even a trivial hello world console app needed two files: a .csproj project file and a .cs file with your top-level statements. That second file, small as it looks, was never really optional. Preview support in .NET 10 removes that requirement. A complete, runnable C# application can now be a single .cs file.
Console.WriteLine("Hello, world");
Save that one line to a file named app.cs and you can execute it straight away with dotnet run, no project file in sight.
> dotnet run app.cs
Hello, world
Under the hood, the SDK builds a virtual, in-memory project for that file using sensible defaults, compiles it, and runs it. You never see the generated project unless you explicitly convert the file to a real one later, which we will cover further down.
The directives available today
A single file has no csproj to hold SDK references, package references, or MSBuild properties. To cover that gap, .NET 10 preview 5 introduces a small set of hash-colon directives that you place at the top of the .cs file. The example below is a no-op Aspire app host that touches every directive currently supported, just to demonstrate the syntax in one place.
#!/usr/bin/dotnet run
#:sdk Microsoft.NET.Sdk
#:sdk Aspire.AppHost.Sdk 9.3.0
#:package Aspire.Hosting.AppHost@9.3.0
#:property UserSecretsId 2eec9746-c21a-4933-90af-c22431f35459
using Microsoft.Extensions.Configuration;
var builder = DistributedApplication.CreateBuilder(args);
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", "https://localhost:21049" },
{ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL", "https://localhost:22001" },
{ "ASPNETCORE_URLS", "https://localhost:17246" },
});
builder.Build().Run();
It does not need to do anything meaningful to demonstrate the point. Every line above a normal using statement is either a shebang or a hash-colon directive, and each one maps to something you would ordinarily write in XML inside a csproj file.
Making the file executable with a shebang
The line starting with #! is a standard Unix shebang, telling the shell which interpreter should run the file. Here it points at dotnet run. Once the file is marked executable, you can invoke it directly instead of typing dotnet run every time.
chmod +x app.cs
./app.cs
This only matters on Linux and macOS, since Windows has no concept of shebang lines. If you are building cross-platform utility scripts, keep that in mind, the shebang line does nothing harmful on Windows, but it also will not make the file executable there.
Adding SDK references
By default a single file app uses Microsoft.NET.Sdk, the standard SDK for console apps. If you are building an ASP.NET Core app or, as in the example above, an Aspire app host, you need a different SDK, and that is what #:sdk sets.
#:sdk Microsoft.NET.Sdk
#:sdk Aspire.AppHost.Sdk 9.3.0
The first line sets the base project SDK. The second adds the Aspire additive SDK, which is versioned and pulled from a NuGet package rather than shipped with the .NET SDK itself. Worth noting: as of preview 5 the version is space separated, but Microsoft has already signalled that this syntax is moving to an @ separator, so #:sdk Aspire.AppHost.Sdk@9.3.0 in a future preview. If you are following along with preview builds, expect to update this line when you upgrade.
Adding NuGet package references
The #:package directive pulls in a NuGet package by name and version, which is the direct replacement for a PackageReference entry in a csproj.
#:package Aspire.Hosting.AppHost@9.3.0
Version wildcards work here too, which is useful during local prototyping when you do not want to chase exact patch versions.
#:package Aspire.Hosting.AppHost@*
#:package Aspire.Hosting.AppHost@9.*
#:package Aspire.Hosting.AppHost@9.3.*
The wildcard resolves to the highest available version matching the pattern. Fine for a scratch script you run once, risky for anything you intend to keep around and re-run months later, since the resolved version can silently drift between runs unless you pin it.
Updating MSBuild properties
The #:property directive covers everything else you would normally set inside a PropertyGroup in a csproj. In the Aspire example it is used to set UserSecretsId.
#:property UserSecretsId 2eec9746-c21a-4933-90af-c22431f35459
Like the SDK directive, this syntax is also due to change from a space separator to an equals sign in an upcoming preview: #:property UserSecretsId=2eec9746-c21a-4933-90af-c22431f35459. If you are writing tutorials or internal documentation around this feature right now, add a note that the exact syntax is still moving, otherwise your readers will hit confusing errors on a newer SDK.
Referencing projects, coming in preview 6
A #:project directive is merged but not yet released as of preview 5. It will let a single file reference an existing project, either by pointing at the csproj directly or just at the containing directory.
#:project ../src/MyProject
#:project ../src/MyProject/MyProject.csproj
Being able to point at a directory rather than the exact csproj filename is a small convenience, but it fits the broader intent behind this feature: reduce friction for people who are not yet comfortable with the mechanics of MSBuild project files.
Who this feature is actually built for
The primary audience is newcomers to C# and .NET. Node.js and Python developers have had a single-file experience for years, run one script, get output, no ceremony. C# has lagged behind here, and the standard dotnet new console template, while simple, still hands a brand new developer a Program.cs file next to a csproj full of XML that means nothing to them on day one.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
With single-file run, that csproj simply does not exist until you need it. A beginner can start from one file and grow into project concepts gradually, instead of being confronted with all of them on the first day.
Beyond teaching scenarios, two practical uses stand out for working developers. Utility scripts are an obvious one, tasks you previously reached for bash or PowerShell can now stay in C#, which matters if C# is the language your team actually knows well. Sample repositories are the other, library authors who currently maintain a folder per sample, each with its own csproj, can collapse that down to one file per sample in a single folder.
- Utility scripts: quick automation tasks where spinning up a full console project felt like overkill
- Samples and demos: library and framework authors who maintain many small standalone examples
It is also worth remembering this is not the first attempt at a single-file C# story. Cake, dotnet-script, CS-Script, and the CSI.exe REPL tool have all covered similar ground in different ways. The difference now is that this lands as first-party tooling in the SDK itself, rather than a third-party package you add to your workflow.
Escape hatches for anything the directives do not cover
The single-file experience is built around a virtual project behind the scenes, and that project still respects the usual MSBuild convention files if they exist in the folder structure above the .cs file. That gives you a way around the current directive limitations without waiting for new #: syntax.
- global.json
- NuGet.config
- Directory.Build.props
- Directory.Build.targets
- Directory.Packages.props
- Directory.Build.rsp
- MSBuild.rsp
Directory.Build.props is the most immediately useful of these. If you keep a folder full of single-file scripts and want to apply one property or package reference to all of them, you set it once in Directory.Build.props instead of repeating a #:property line in every file. Teams standardizing tooling scripts across a repository will likely lean on this rather than duplicating directives file by file.
Growing out of a single file
Eventually a script grows past what a single file comfortably supports, maybe you need a second .cs file, or a proper test project alongside it. Converting is a single command.
dotnet project convert app.cs
Running that against the Aspire example above generates a full csproj with every directive translated into its XML equivalent: the SDK references, the package reference, the UserSecretsId property, plus the framework defaults that were implicit before.
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.3.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<UserSecretsId>2eec9746-c21a-4933-90af-c22431f35459</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.3.0" />
</ItemGroup>
</Project>
Nothing is lost in the conversion, every directive you wrote maps cleanly to its project file equivalent. This is a good sign for adoption: teams can prototype quickly as a single file and graduate to a full project the moment it earns its own folder, without rewriting configuration from scratch.
What is landing soon but is not here yet
A few related features are already merged for a later preview even though they are not in preview 5. First, publishing. Right now you can only run a single file app, not publish it, but dotnet publish app.cs is already merged for the next preview.
dotnet publish app.cs
By default these publish as Native AOT binaries, which fits the scripting use case well since AOT startup times matter more for a short-lived utility script than a long-running web service. You can opt out with #:property PublishAot false if AOT compilation causes trouble with a dependency that is not AOT-compatible.
Second, running without typing run explicitly. The shorter dotnet app.cs form is merged for a future preview.
dotnet app.cs
This mainly helps shebang lines on Linux. The previous approach needed something like #!/usr/bin/env dotnet run, which some shells pass to env as a single argument rather than two, breaking the shebang. The shorter dotnet app.cs form lets you write #!/usr/bin/env dotnet instead, which works consistently across shells.
#!/usr/bin/env dotnet
// Works reliably across shells, unlike the two-argument env dotnet run form.
Third, and this one deserves a word of caution: piping C# code directly into dotnet run from stdin is also merged.
> 'Console.WriteLine("Hello, World!");' | dotnet run -
Hello, World!
This is convenient for quick one-liners, but it opens the same door as curl-ing a shell script off the internet and piping it straight into bash. Downloading and executing arbitrary C# from a URL without reading it first is a bad habit regardless of language, and this feature makes that pattern just as easy in .NET as it already is in shell scripting. Treat it the same way you would treat any curl-pipe-to-shell command: read it first, then run it.
What is explicitly not coming
Multiple file support was originally planned for .NET 10 but has been pushed to .NET 11 so the team can focus on getting the single-file experience solid first. You can work around this today using Directory.Build.props and Directory.Build.targets to pull in additional files manually, but there is no native way yet to have one app.cs reference a sibling helper.cs file directly.
Visual Studio support is also not planned. First-party tooling support will be limited to Visual Studio Code and the CLI. If your team standardizes on full Visual Studio, factor that into any decision to build tooling around this feature, your VS users will not get the same experience as VS Code users.
Finally, this is a C# only feature. There is no plan to bring single-file support to VB.NET or F#, though the team has not ruled it out permanently.
Practical take: where this actually helps
For teaching and onboarding, this is a genuine improvement. Removing the csproj from a beginner’s first few days with C# lowers the barrier without dumbing anything down, since the project file is still there the moment you need it, just generated on demand instead of upfront.
For utility scripts and demos, this is where I would actually reach for it in production-adjacent work. A deployment helper script, a one-off data migration check, a repro case for a bug report, all of these are good candidates. I would not build anything long-lived or team-shared as a single file once it crosses roughly fifty lines or picks up more than one or two package references, at that point dotnet project convert app.cs takes a few seconds and gives you a normal project with normal tooling support around it.
One limitation worth flagging for teams evaluating this now: the directive syntax is still changing preview to preview, the #:sdk and #:property separators alone have already shifted once in the examples above. If you are documenting this feature internally before .NET 10 goes GA, expect to revisit that documentation, and avoid committing single-file scripts with directives into shared repositories until the syntax stabilizes in a stable release.
Leave a Reply