Swift 6.2: Subprocess
Swift 6.2 introduces a new
Subprocesspackage that offers a streamlined, concurrency‑friendly API for launching and managing external processes.
The existing Foundation API for spawning a process,
NSTask, originated in Objective-C. It was subsequently renamed toProcessin Swift. As the language has continued to evolve,Processhas not kept up. It lacks support forasync/await, makes extensive use of completion handlers, and uses Objective-C exceptions to indicate developer error. This proposal introduces a new package calledSubprocess, which addresses the ergonomic shortcomings ofProcessand enhances the experience of using Swift for scripting and other areas such as server-side development.
It’s currently available as a package, rather than being built into the OS, so the API may not be fully stable, but you can use it on older OS versions.
One of the issues with NSTask/Process is that the simple API works for small amounts of input/output but (unbeknownst to many, even some experts) hangs once you exceed the OS’s buffer size. There are ways around this, but they are awkward (though you can hide them in a helper class). As Christian Tietze (Mastodon) writes:
Helge Heß pointed out that naive usage of
Pipein childProcesses can break your program if you pipe too much data.[…]
If the data is larger than the pipe buffer, you need to drain the corresponding
FileHandlewith repeated read calls. (Or provide data larger than 64KiB with repeated write calls, respectively.)If you try to send/receive the whole buffer in one go, from a user’s perspective, your program will freeze, and the read call never return. As a CLI app, it’ll never terminate.
Subprocess seems to be designed to transparently handle this. You can specify the output size limit (and the encoding, if you want a string output), and it will collect the pieces of output as they arrive.
I’ve been using Swift to write scripts, and scripts often need to run shell commands. Unfortunately, Subprocess is currently cumbersome to use for scripting, as Jacob Bartlett writes:
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.
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.
Previously:
Update (2025-12-01): Sarah Reichelt:
There are immediate benefits to using Subprocess. I didn't have to specify the full path to the
whoamicommand, and I didn't have to set up a pipe and a file handle to see the result.[…]
That's when I found out that
argumentsmust be an array ofArguments, notStrings.
It’s not an Array of Arguments; Arguments itself is an array-like type. The comment says it’s “a collection,” but it does not actually seem to be a Collection. Anyway, this is annoying because I’m often constructing arguments lists in code rather than using literals. I wonder if there wasn’t a better way to provide the executablePathOverride functionality than by making all the users who don’t need that use a whole new type.
This worked, but I was still getting the complete output at the end, and not seeing each line as it arrived. To help in my research, I selected Build Documentation from the Product menu which gave me the docs for
Subprocessin Xcode's Developer Documentation. There are a lot of custom types, but only one function -run- which has 14 different versions. Seven of these variants expect anExecutable, which is what I've been using when I specify a.name. The others expect aConfigurationwhich is a way of combining an executable and its arguments plus other configuration details, into a single object.I also discovered that the versions that streamed data had a
preferredBufferSizesetting.[…]
I set the
preferredBufferSizeto 32 and watched the lines come in one by one. It would be great if there was an option to buffer until a line feed, but this is workable.