Swift. The general-purpose programming language designed for safety, performance, and ease of use. This was the sales pitch, but the language has consistently struggled to break out of the prison of the mobile niche, towards other mainstream workflows.
Scripting is one of those use cases that has theoretically been possible in Swift since forever, but is so painful to work with, it’s functionally useless, even for trivial workloads.
Until today.
Kind of.
Today we’re going to look at the brand-new, hot-off-the-press, seriously-it-only-got-released-this-Friday swift-subprocess. It’s a new package that promises to solve some of the chronic, incandescent pain points that Swifty scriptwriters were living with.
Today, we’re going to look at the weird world of scripting:
What is scripting? Why does Swift scripting suck?
How can we use swift-subprocess to automate stuff in Swift?
(because apparently I’m an influencer now) what do I really think?
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.
Paid members get several benefits:
Access Elite Hacks, my exclusive advanced content 🌟
Read my free articles a month before anyone else 🚀
Grow your career with my Swift Concurrency course 🧵
If you’re not sure about upgrading yet, consider how much of your life you can automate if you got the hang of Swift scripts. Appraise the value of your time. Ponder whether you’ll save more than $12 worth of time in the first month. Perhaps the premium subscription will have already paid for itself!
A primer on scripting
A script is just a text file that gets executed directly by an interpreter. They are perfect for creating “glue” code that stitches different operations and programs together.
In UNIX systems, you’ll be familiar with tools like ls, cat, pwd, echo, and mv. These are actually tiny executable utilities installed on your system, such as /bin/ls.
Bash scripts seamlessly interact with these utilities because the execution environment is specifically designed to orchestrate processes. In (massively over-) simplified terms, the UNIX shell is a loop that reads a command, spawns a process, executes said command, and waits for it to complete.
The shell “just works” because it spawns these child processes, executes commands (like ls, curl, and ffmpeg) in the child process, then pipes results back into the parent’s execution environment automatically, without you needing to think about it.
Swift Scripting
Swift is a strongly-typed, statically-compiled language. So, exactly what you don’t want when you’re running a script. Since early Swift, we’ve been able to run ‘scripts’ by throwing a shebang atop of our .swift file:
#!/usr/bin/swift
You can run these swift scripts exactly like a regular shell script:
./my_script.swift
So what’s the problem?
Right, I’ll try not to go off on one. But first off, while this looks and sounds like a run-of-the-mill script, shebang and all, it smells off. Because it’s not a script at all. The Swift code is compiled and executed, assuming all the corresponding overhead.
But moreso, the UNIX shell has spoiled us.
Process forking and result piping seems to come free in Bash because it’s cleverly abstracted away from us. Swift simply hasn’t been built to work like that.
Therefore, even for something as trivial as ls, to list files in a directory, we’re forced to invoke boilerplate Foundation APIs, explicitly forking child processes and manually piping the stdout back into our parent process.
This verbosity isn’t laziness on the part of the Swift language developers: process management and piping semantics just work differently between a Windows server, a MacBook Pro, and an embedded IoT device.
A general-purpose cross-platform programming language like Swift is just subject to different design constraints than an OS-linked execution environment like the UNIX shell.
swift-subprocess
swift-subprocess was pitched as part of the swift-foundation project, and released for the first time this week (as of the time of writing, the first week of September 2025).
It was proposed with 2 goals in mind:
Improve the ergonomics of Swift process creation and result piping.
To open scripts up to modern language semantics like async/await.
It's fundamentally built around the run function.
The executable can be referenced either via the full file URL, or more conveniently using something like .name(“ls”) to directly fetch it from $PATH to allow for more Bash-y ergonomics.
There’s several overloads of run, including a closure-based version that streams the subprocess output as an AsyncSequence of data.
swift-subprocess calls into low-level platform APIs, such as posix_spawn on UNIX systems, to achieve portability from the same API across all platforms.
For a primer on how the swift-foundation uses platform APIs to win cross-platform portability, check out my recent deep-dive on Data:
Simple Subprocess Workflows
Here's the same ls script from before, ported over to use the new swift-subprocess:
Looks great, huh?
This is the beautiful dream woven by the original swift-subprocess proposal.
But I’m lying to you.
This was cruel, but I thought it would be illustrative to take you on the same emotional rollercoaster I was the first time I excitedly Tweeted about the hot new package, before promptly being a bit disappointed when I started actually playing with it.
Allow me to drown the puppy quickly, and explain how the beautiful ergonomics of the proposal conflict with the cold reality of Swift's design limitations. 🐶
To use swift-subprocess, you need some more ceremony.
A lot more ceremony, actually.
To use Subprocess in your script, it needs to be built as part of an SPM project. The package at your top-level defines the package name, the swift-subprocess dependency, and finally the executable targets you’ll create.
Our package creates an executableTarget. In an SPM project, this is basically a small CLI tool, complete with @main entry point and the ponderous thesaurus of Swift syntactic rules you know and tolerate. No shebang in sight.
You might be drawn to this string(limit:) syntax on the output:
.string(limit: 1 << 20)
This basically sets the size limit of the buffer that returns the data from the process. It’s a cheeky bitwise operation that left-shifts the value of “1” bit by 20 bits, giving a capacity of 2²⁰, or ~1MB of output before throwing an error.
We can easily build & run the program as part of the SPM project.
swift run ls
swift run builds and, well, runs the executable ls target we defined in our package.
This build, and subsequent dependency resolution, creates a level of first-run overhead that makes the use case of a quick throwaway script almost laughable:
Subsequent launches are much faster, caching stuff in a .build/ folder, but this does little to mask the lie we tell ourselves.
The lie that this is a script at all.
Once we accept reality, we can think about actually doing something productive. We can write similar not-scripts-but-actually-conveniently-fast-CLI-tools that perform operations such as…
…zipping a folder:
…or logging the git diff:
These simple scripts have ludicrous amounts of overhead for what are trivial one-liner operations in the bash shell. Bear with me, because later we’ll look at where Swift subprocess can actually work well, in a more complex workflow.
Complex Workflows
Frankly, I'm not happy with the amount of ceremony we have to create in order to write simple tools.
But, given that we're building a Swift package project anyway, Subprocess shines when we’re orchestrating lots of tools together, such as reading files, fetching from the network, and processing media.
In this final tool, we start with a list of URLs in thumbnails.txt.
We’re going to do something interesting with this list. Here’s the top-level workflow:
In this more complex pipeline, we’ve refactored all the actual process invocations into separate little helper functions, such as…
…reading URLs from the thumbnails text file:
…directory manipulation to create a temporary directory:
…fetching images using curl and converting them into png format:
…and stitching together the images into a single video using FFmpeg:
This full script takes us from a list of URLs at thumbnails.txt, downloads each file into a tmp directory, converts them to PNGs, then collates them all together into a single mp4 video.
Neat, right?
The identical version in Bash will get you a much better score at code-golf, but you can tell me which version feels more maintainable if you want to extend the behaviour or reuse some of the helper functions.
Let’s Get Real.
Having played with it for a couple of days, swift-subprocess is a double-edged sword.
The additional overhead of requiring a full SwiftPM project, compared to a Bash script, makes it incredibly cumbersome for simple workloads. Also, it’s still not an actual script, so it’ll always require a compilation (and potentially dependency resolution) overhead whenever your code changes.
On the other hand, the syntax, type safety, and composability of Swift code work pretty nicely when you have complex automation workflows to orchestrate, build, and run on demand.
I’ve worked with Swift CLI tooling before, and felt the pain of maintaining a brittle executable that has to be manually rebuilt every time you change some code. Subprocess, in conjunction with SPM, introduce a far nicer dev-ex for maintaining CLI tooling. And that should be celebrated.
I foresee Swift Subprocess tools being a great fit for continuous integration workflows.
A typical release workflow script might tie together GitHub, JIRA, and Fastlane to cut & tag release branches, mark relevant tickets as part of this release, then deploying the release to the App Store respectively. Tooling built with SPM and Subprocess will reduce maintenance complexity and allow way better reusability than an identical Bash script workflow.
Let’s live in the real world for a moment: LLM-assisted engineering has reduced the marginal cost of a throwaway Bash script to zero, and LLMs are far more proficient at Bash than they are at Swift. So before you insist on re-writing all your internal tooling in Swift, just take a minute to consider whether you’re adding or removing complexity.
Last Orders
Scripting has always been an exotic, quixotic use case for our favourite strongly-typed compiled language.
Subprocess does little to change that: we still aren’t actually writing script, but at least we stopped pretending to do so: introducing the ceremony of a full SPM project and a proper executable CLI target.
But this is a good thing. It moves Swift away from the “pure scripting” use case and allows it to shine as a syntactically attractive language for automated CLI tooling, that runs with a simple swift run command.
In the vast majority of use cases, especially in the age of LLM-assisted engineering, a Bash script is both faster and easier to work with, but there are use cases such as CI automation where maintaining tooling in Swift might be more painless for your team in the long run.
If not, perhaps it’s okay for a language to play to its strengths rather than being a jack of all trades (and master of none).
Install my open-source project here to play with the scripts yourself!
Thanks for reading Jacob’s Tech Tavern! 🍺