Every couple of weeks I need a twenty-line program. Find what's bloating a build agent's disk, dedupe a CSV, hash-check a folder. For fifteen years the honest answer to "which language?" was not C# — by the time I'd done mkdir, dotnet new console, and named yet another throwaway csproj, the moment had passed. So those little jobs went to bash or Python, and I grumbled quietly every time.
.NET 10 removed the ritual. You write one .cs file and run it. I'd been meaning to check how well this actually holds up for real scripts, so this week I did — nothing fancy, one Linux container and a stopwatch.
One file, no project
Here's biggest.cs, a small utility that lists the largest files under a directory. The whole program is this one file — no csproj anywhere:
#!/usr/bin/env dotnet
#:package Humanizer@3.0.10
using Humanizer;
var root = args.Length > 0 ? args[0] : ".";
var top = args.Length > 1 && int.TryParse(args[1], out var n) ? n : 10;
var files = new DirectoryInfo(root)
.EnumerateFiles("*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint
})
.OrderByDescending(f => f.Length)
.Take(top)
.ToList();
foreach (var f in files)
{
var size = f.Length.Bytes().Humanize("#.#");
var age = (DateTime.UtcNow - f.LastWriteTimeUtc).Humanize();
Console.WriteLine($"{size,10} {f.FullName} (modified {age} ago)");
}
Enter fullscreen mode Exit fullscreen mode
Two lines are new. #:package [email protected] is a NuGet reference written as a directive, right in the source. The shebang we'll get to in a minute. Everything else is the C# you already write, top-level statements and all.
$ dotnet run biggest.cs -- ~/.dotnet 5
Top 5 files under /root/.dotnet:
37.6 MB .../FSharp.Compiler.Service.dll (modified 46 seconds ago)
18.7 MB .../Microsoft.CodeAnalysis.CSharp.dll (modified 46 seconds ago)
18.7 MB .../Roslyn/bincore/Microsoft.CodeAnalysis.CSharp.dll (modified 45 seconds ago)
14.9 MB .../System.Private.CoreLib.dll (modified 45 seconds ago)
11.4 MB .../native/singlefilehost (modified 47 seconds ago)
Total: 101.1 MB
Enter fullscreen mode Exit fullscreen mode
Yes, the .NET SDK's biggest file is the F# compiler. I enjoyed that more than I should have.
The stopwatch part
The obvious worry: is this Python-fast to iterate on, or does every run pay the compiler tax? I timed it on SDK 10.0.302 in a Linux container — not a lab, I care about the ratios, and warm numbers are best of three.
The first ever run took 10.5 seconds. That's the worst case by design: it restored Humanizer, compiled, and even sat through the CLI's first-run welcome banner. After that, an unchanged script runs in about 0.3 seconds, and rerunning right after an edit lands just under half a second. Half a second from save to output is firmly in scripting territory. I stopped noticing the compiler was there, which is the whole point.
The other thing I checked: my folder still contains exactly one file. No bin/, no obj/. The build products get squirreled away under ~/.local/share/dotnet/runfile/, one content-hashed folder per script, so the place where your script lives stays as clean as a bash script's folder would. dotnet biggest.cs works too, if typing run offends you.
chmod +x, because why not
That shebang line isn't decoration:
$ chmod +x biggest.cs
$ ./biggest.cs /tmp 3
22.3 MB /tmp/phantomjs/phantomjs-2.1.1-linux-x86_64.tar.bz2 (modified 12 weeks ago)
...
Enter fullscreen mode Exit fullscreen mode
A C# file behaving like a first-class shell script, cron-able and CI-able. My opinion, stated as such: for anything past one pipe and a grep, I'd now rather have this than the pile of bash I used to babysit. String handling in bash is where my weekends go to die, and every one of those scripts is one #:package away from a real JSON parser or a real HTTP client.
Where the party ends
It's genuinely single-file, at least on the SDK I tested. I dropped a Helper class into a util.cs next to my script and got error CS0103: The name 'Helper' does not exist in the current context. Running a file compiles that file, full stop.
I've decided I like the bluntness, because wanting a second file is exactly the signal that your script stopped being a script. The graduation path is one command:
$ dotnet project convert biggest.cs
Enter fullscreen mode Exit fullscreen mode
That generates a biggest/ folder with the source and a proper csproj, shebang and directives stripped, the #:package line turned into a real PackageReference. Funny detail: the generated project came with PublishAot and PackAsTool already set to true — the SDK has strong opinions about what your script wants to grow into.
When not to use this: anything a teammate maintains with you, anything that needs tests, anything with more than one file's worth of ideas. And remember that cold start — 10 seconds of restore on first run means ephemeral CI containers pay the tax every time unless you cache the NuGet folder.
But for the twenty-line jobs? I've stopped reaching for Python. Ten seconds once, a third of a second forever after, and it's the language I actually think in.
Full runnable sample: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/016-file-based-csharp
What's the smallest job you've ever created an entire solution for? Mine involved three lines and a .sln file I'm still ashamed of. Tell me yours in the comments.
— still timing things nobody asked me to time
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.