Skip to content

Run a subprocess and capture its output snippet

Running a command and capturing its output has one security rule and one deadlock rule.

Running a command and capturing its output has one security rule and one deadlock rule. Security: pass an argv LIST — never splice user input into a shell string, or every space and semicolon in it becomes a metacharacter (command injection). Deadlock: capture stdout AND stderr or the child blocks the moment its pipe buffer fills — a few hundred kilobytes of output hangs the naive read-one-stream loop. Exit codes are data: check them, do not assume zero.

Runnable recipe · 13 languages
System & CLIsubprocessshellexeccliprocesssecurity

Every language

13 implementations, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const run = promisify(execFile); // execFile: argv list, NO shell

const { stdout } = await run('git', ['status']); // injection-safe
console.log(stdout);

exec() splices the command into one SHELL string — a semicolon in user input is command injection. execFile('git', ['status']) takes an argv list and never touches a shell; promisify turns its callback into an awaitable.

TSTypeScript
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);

export async function run(
  cmd: string,
  args: readonly string[]
): Promise<{ stdout: string; stderr: string; code: number }> {
  try {
    const r = await execFileAsync(cmd, args, { encoding: 'utf8' });
    return { stdout: r.stdout, stderr: r.stderr, code: 0 };
  } catch (err: any) { // non-zero exit lands HERE
    return { stdout: err.stdout, stderr: err.stderr, code: err.code };
  }
}

execFile resolves ONLY on exit code 0 — every other exit rejects, with the streams riding the rejection (err.stdout, err.stderr, err.code). Catch and repackage into {stdout, stderr, code}, or callers lose the output of every failing command.

GoGo
import (
	"fmt"
	"os/exec"
)

out, err := exec.Command("git", "status").Output() // argv, no shell
if err != nil {
	if exitErr, ok := err.(*exec.ExitError); ok {
		fmt.Fatalf("exit %d: %s", exitErr.ExitCode(), exitErr.Stderr)
	}
	fmt.Fatalf("spawn failed: %v", err) // binary missing, etc.
}
fmt.Print(string(out))

Output() captures stdout; CombinedOutput() merges stderr into it — both drain the pipes internally, so no deadlock. cmd.Run runs WITHOUT capture (it only returns the error). A non-zero exit is a non-nil error carrying *ExitError; ExitCode() is the data. Pre-spawn failures (ENOENT) are not ExitErrors.

RsRust
use std::process::Command;

let out = Command::new("git")
    .arg("status")  // argv — there is no shell to inject into
    .output()       // captures stdout AND stderr, no deadlock
    .expect("failed to start git");

println!("exit={}", out.status.code().unwrap_or(-1));
println!("stdout:\n{}", String::from_utf8_lossy(&out.stdout));

output() spawns reader threads for both pipes — the deadlock class is designed out. status() runs the same command and returns ONLY the exit status, no capture. code() is None when the child was killed by a signal, not exited.

PHPPHP
$proc = proc_open(['git', 'status'], [ // argv ARRAY — no shell
    1 => ['pipe', 'w'],
    2 => ['pipe', 'w'],
], $pipes);

$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);

$code = proc_close($proc); // exit code is data

shell_exec() and backticks ALWAYS go through sh — user input in that string is injection. proc_open with an ARRAY argv bypasses the shell entirely. When a shell is truly needed (pipes, globs), wrap every argument in escapeshellarg() — quoting is not optional.

PyPython
import subprocess

r = subprocess.run(
    ['git', 'status'],    # argv list — no shell
    capture_output=True,  # stdout AND stderr, no deadlock
    text=True,            # str, not bytes
    check=False,          # non-zero exit is not an exception
)

print(r.returncode, r.stdout, r.stderr)

run() (3.5+) replaced the check_output/call/Popen-communicate ceremony. check=False keeps a non-zero exit as r.returncode instead of raising CalledProcessError — branch on the code, don't assume zero. text=True (formerly universal_newlines) avoids the bytes-not-str surprise.

CC
#include <stdio.h>
#include <sys/wait.h>

FILE *p = popen("git status", "r"); // ALWAYS runs via sh -c
if (!p) { perror("popen"); return 1; }

char line[256];
while (fgets(line, sizeof line, p)) // drain stdout (stderr not captured)
    fputs(line, stdout);

int st = pclose(p); // ENCODED wait status, not the exit code
if (WIFEXITED(st))
    printf("exit=%d\n", WEXITSTATUS(st));

popen ALWAYS goes through sh — untrusted input in that string is command injection; posix_spawn with an argv array is the no-shell route. pclose's return is the raw waitpid status: decode it with WIFEXITED/WEXITSTATUS — WEXITSTATUS(st), never st itself.

C#C#
using System.Diagnostics;

var psi = new ProcessStartInfo("git")
{
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    UseShellExecute = false, // required for redirects — and no shell
};
psi.ArgumentList.Add("status"); // argv entries — NOT an Arguments string

using var process = Process.Start(psi)!;

// Start BOTH readers before waiting — draining one pipe while the
// other fills is the deadlock again, just sequential:
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync();
Task<string> stderrTask = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();

Console.WriteLine($"exit={process.ExitCode}");
Console.WriteLine(await stdoutTask);
Console.Error.WriteLine(await stderrTask);

ArgumentList.Add() appends one argv entry — the Arguments STRING re-introduces quoting and injection bugs. Kick off both ReadToEndAsync tasks, THEN WaitForExitAsync: awaiting stdout while stderr fills its pipe deadlocks. UseShellExecute=false is mandatory for any redirect.

JvJava
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.List;

Process p = new ProcessBuilder(List.of("git", "status")) // argv — no shell
        .redirectErrorStream(true)                        // merge the streams
        .start();

// Read the output BEFORE waitFor — the child blocks when its pipe
// buffer fills, and waitFor blocks waiting for the child: deadlock.
StringBuilder sb = new StringBuilder();
try (BufferedReader r = new BufferedReader(
        new InputStreamReader(p.getInputStream()))) {
    r.lines().forEach(line -> sb.append(line).append('\n'));
}

int code = p.waitFor(); // NOW safe — pipes already drained
System.out.print(code + "\n" + sb);

The ordering rule IS the bug class: drain getInputStream() to EOF before waitFor(), or both sides block — a few hundred KB of output hangs it. redirectErrorStream(true) merges stderr into stdout so one reader captures both. waitFor() returns the exit code — it is data.

SwSwift
import Foundation

let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = ["status"] // argv array — no shell

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe // both streams, one reader

try process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit() // AFTER the drain

let output = String(data: data, encoding: .utf8) ?? ""
print("exit=\(process.terminationStatus)")
print(output)

readDataToEndOfFile() BEFORE waitUntilExit() — same ordering rule as Java: the child blocks on a full pipe while you wait. Sharing one Pipe merges the streams; for separate pipes, read each on a background queue via readabilityHandler. terminationStatus is the exit code.

KtKotlin
val process = ProcessBuilder(listOf("git", "status")) // argv — no shell
    .redirectErrorStream(true)
    .start()

// Drain the merged stream BEFORE waitFor — same JVM deadlock rule:
val output = process.inputStream.bufferedReader().readText()
val code = process.waitFor()

println("exit=$code\n$output")

Same JVM rules as Java — argv list (never a shell string), redirectErrorStream(true) to merge the pipes, drain before waitFor. readText() replaces the whole read-loop: the shortest safe spelling on the JVM.

RbRuby
require 'open3'

stdout, stderr, status = Open3.capture3('git', 'status') # argv, no shell

puts status.exitstatus # exit code is data
puts stdout

Open3.capture3 takes an argv array and never spawns a shell — backticks and system('git status') do. It drains both streams to EOF on a wait thread (the '3'), so no pipe deadlock. The third return is a Process::Status: .exitstatus is the code, .success? the predicate.

ZigZig
const std = @import("std");

const result = try std.process.Child.run(.{
    .allocator = allocator,
    .argv = &.{ "git", "status" }, // argv slice — std.process has no shell
});

defer allocator.free(result.stdout);
defer allocator.free(result.stderr);

switch (result.term) {
    .Exited => |code| std.debug.print("exit={d}\n{s}", .{ code, result.stdout }),
    else => std.debug.print("killed by signal\n", .{}),
}

Child.run captures both streams on threads internally — no deadlock to hand-roll, and there is no shell anywhere in std.process to opt into. result.term is a tagged union: .Exited carries the code; a signaled child is .Signal, not exit 1. The returned buffers are owned by your allocator — free them.