Reading Piped Input in Swift Executables

Published: May 23, 2025
Written by:
Natan Rolnik
Natan Rolnik

A very common scenario when using CLI tools is to chain commands together. In Bash, this is done with the pipe operator, |, which connects the standard output from the first command, to the standard input of the second command. Here are some examples:

  • cat file.txt | grep "hello": This prints the contents of file.txt, and pipes it to the grep command which will look for the string hello.
  • curl https://some-api.com/data | jq .username: This makes a URL request using curl, and pipes the response to the jq command which will parse the JSON response and print the value of the username key.

While we may read these command pairs as synchronous, meaning that the second command will only start after the first one has finished, in reality they run in parallel. As soon as any output from the first command is available, it is piped to the second command, until the first command finishes.

In Swift, reading data from standard input is also possible, including with async/await support via AsyncSequence. This article will explore how to check if there’s piped data available to read from, and how to read it, by building a simple tool that will read JSON data from standard input and print it in a beautified, more readable format.

Available APIs

The Swift standard library provides a few APIs to allow reading content from standard input.

Accessing the Standard Input

  • First, there is the standardInput property in the Process class. When initializing a process, this property will be set to the standard input of the current process, and while it is defined as Any?, in runtime it will be a FileHandle object (although it can in some cases be a Pipe object in other scenarios). It can be accessed in the following way:
let standardInput = Process().standardInput as? FileHandle
// Or use guard let to unwrap the value if preferred
  • Another API, more type-safe than the previous one, is the standardInput static property in the FileHandle class. This property is set to the standard input of the current process, and there’s no need to cast the value to a FileHandle:
let standardInput = FileHandle.standardInput

Reading Data from the Standard Input

As explained in the introduction, the standard input by definition emits its data as a stream, and reading it is an asynchronous operation. Therefore, once you have the standard input file handle, there are a few ways to read data from it:

  1. readDataToEnd(): This method will read all the data from the standard input until the end of the stream, and return an optional Data. This method is synchronous, and it will block the running thread until all the data is read.
  2. readData(ofLength:): Another alternative is to read the input data in chunks, and process it as it becomes available. This method is also synchronous, and you should check if the returned chunk is empty to know when to stop reading:
while true {
    // Reading up to 1 KB at a time, or any other size you prefer
    // Check if the chunk is empty to know when to stop
    guard let data = try stdInput.read(upToCount: 1024),
          !data.isEmpty else {
        break
    }

    inputData.append(data)
}
  1. Finally, the newest approach is to use the bytes property, which returns an AsyncSequence of UInt8 values. This allows you to use an async for-await loop to read the data, and the sequence will automatically finish when reaching the end of the stream:
var inputData = Data()
for try await byte in stdInput.bytes {
    inputData.append(byte)
}

It is important to not that all these 3 options will block the executing thread until all the data is read. Even more important, if there’s no standard input data available to read from at all, the thread will block indefinitely. Therefore, you should always check if there’s available data to read from before trying to do so.

Checking the Standard Input

To check the presence of piped data, the option is to use a function from the C standard library: isatty(). It checks if a given file descriptor is associated with a terminal, and returns 1 or 0 accordingly:

  • It returns 1 (or any nonzero value) if the file descriptor is a terminal, for example, if you execute the program directly in a terminal, with no piped input.
  • It returns 0 if the file descriptor is not a terminal, meaning that the standard input is being piped from a file or another program.

With the help of another C function, fileno(), you can get the file descriptor of the standard input, and pass it to isatty():

guard isatty(fileno(stdin)) == 0 else {
    print("No piped input data provided")
    exit(1)
}

In this example we stop the execution by exiting the program with the error code 1. Many tools try, instead, to check for the presence of an argument or other fallback mechanisms, without exiting the program. You could use a similar approach, for example, by checking if any arguments are provided, and only if not, to query the standard input.

Putting it All Together

Back to our example, we’ll use the bytes property to read the data from the standard input, and then we’ll parse it as JSON, converting it back to a pretty printed string.

First, check if there’s piped data available to read from, with the isatty() function the previous section explained:

import Foundation

@main
struct JSONPiper {
    static func main() async throws {
        guard isatty(fileno(stdin)) == 0 else {
            print("No input data provided")
            exit(1)
        }

        // Read the data from the standard input...
    }
}

Then, use the bytes property to read the data from the standard input as an async sequence, as described above:

let stdInput = FileHandle.standardInput
let bytes = stdInput.bytes
var inputData = Data()

for try await byte in bytes {
    inputData.append(byte)
}

Next you’ll pass the input data to a beautifyAndPrintJSON(_ data: Data) function. But first, implement it:

private static func beautifyAndPrint(_ data: Data) {
    //1
    guard !data.isEmpty else {
        print("No input data provided")
        exit(1)
    }

    do {
        //2
        let jsonObject = try JSONSerialization.jsonObject(
            with: data,
            options: []
        )
        //3
        let prettyPrinted = try JSONSerialization.data(
            withJSONObject: jsonObject,
            options: .prettyPrinted
        )

        //4
        let outputString = String.init(data: prettyPrinted, encoding: .utf8) ?? ""
        print(outputString)
    } catch {
        //5
        print("Error converting JSON: (error.localizedDescription)")
    }
}

If you come from iOS development, especially before Codable was a thing, you may be familiar with the JSONSerialization class. Here’s what the code above does.

  1. Check if the input data is empty, and if so, print an error message and exit the program.
  2. Decode the data to a JSON object.
  3. Convert the JSON object to a pretty printed string.
  4. Print the pretty printed string.
  5. All that’s left is to handle a possible error if the data is not a valid JSON. Catch it and print an error message.

Finally, call the beautifyAndPrintJSON() function with the input data:

beautifyAndPrintJSON(inputData)

And here’s how it looks like when running it:

$ echo '{"device":"iPhone","features":["FaceID","MagSafe"],"specs":{"chip":"A17"}}' | ./JSONPiper
voilá!
voilá!

Explore Further

The world of CLI tools is vast, and there are many more scenarios to explore! Reading from the standard input is just one of them. Here are some resources to keep exploring:

I hope you enjoyed this tutorial! Feel free to ask any questions, send suggestions or feedback via Mastodon or X.

See you at the next post. Have a good one!
Swift, Xcode, the Swift Package Manager, iOS, macOS, watchOS and Mac are trademarks of Apple Inc., registered in the U.S. and other countries.

© Swift Toolkit, 2024-2025