# duct: running child processes in Rust without the shell's surprises

> duct is a Rust library for running child processes, and its argument is about correctness rather than convenience. Whitespace in a command is never significant, a child that exits non-zero is an error unless you say otherwise, and the platform inconsistencies that make process handling awkward are handled inside the crate.

**oconnor663/duct.rs** — a Rust library for running child processes

- Repository: https://github.com/oconnor663/duct.rs
- Stars: 1,047 · Forks: 45
- Language: Rust
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/oconnor663-duct-rs

## A process library, not a shell

duct is a library for running child processes. Duct makes it easy to build pipelines and redirect IO like a shell, and at the same time it helps you write correct, portable code.

The three properties the README names are worth taking one at a time, because they are the reason the crate exists rather than a convenience wrapper around std::process.

Whitespace is never significant. A command is a list of arguments, so a filename with a space in it is one argument rather than two, and there is no quoting to get wrong. This is the single most common source of bugs in code that builds a command line by concatenating a string, and duct removes the category entirely rather than documenting the rules.

Errors from child processes get reported by default. A child that exits with a non-zero status returns an error, which means a failed build step or a failed git command cannot be silently ignored unless you ask for that explicitly.

And a variety of gotchas, bugs and platform inconsistencies are handled for you, with a link to a document in the author's Python repository that lists them. The word platform is doing real work in that sentence, because process handling differs between Unix and Windows in ways that are tedious to discover one at a time.

The crate is MIT licensed, published on crates.io as duct, with the documentation on docs.rs and the source in this repository. The last push was on 2026-09-20 and the repository is not archived, although there are no GitHub releases, so the crate version rather than a tag is the version reference.

## Five examples, and they are the documentation

The README's examples are short enough to read in a minute, and between them they cover most of what the crate does.

Running a command without capturing any output prints directly to the terminal:

```rust
use duct::cmd;
cmd!("echo", "hi").run()?;
```

Capturing the standard output returns it as a String:

```rust
let stdout = cmd!("echo", "hi").read()?;
assert_eq!(stdout, "hi");
```

Capturing the output of a pipeline is the same call with a second command piped in:

```rust
let stdout = cmd!("echo", "hi").pipe(cmd!("sed", "s/i/o/")).read()?;
assert_eq!(stdout, "ho");
```

That third example is the one that makes the library feel different from std::process. Piping is a method on the command rather than something you assemble from three pipes and a reader, and the second command is still a duct expression, so it keeps the same error and whitespace behaviour.

Merging standard error into standard output and reading both incrementally is the fourth:

```rust
use duct::cmd;
use std::io::prelude::*;
use std::io::BufReader;

let big_cmd = cmd!("bash", "-c", "echo out && echo err 1>&2");
let reader = big_cmd.stderr_to_stdout().reader()?;
let mut lines = BufReader::new(reader).lines();
assert_eq!(lines.next().unwrap()?, "out");
assert_eq!(lines.next().unwrap()?, "err");
```

The reader method hands back something you can read incrementally, which matters for a long-running child whose output you want to stream rather than buffer.

## Failure is an error until you say otherwise

The last example in the README is about exit status, and it is two lines long:

```rust
let result = cmd!("false").run();
assert!(result.is_err());
let result = cmd!("false").unchecked().run();
assert!(result.is_ok());
```

A child that exits with a non-zero status returns an error, and the unchecked call makes the same invocation succeed. The naming is deliberate, and it tells you at the call site that you have taken responsibility for the status.

This is a small API decision with a large effect on how a codebase behaves. The default is the safe one, so a build script that fails does not proceed, and a git command that fails does not look like success. The cost of that safety is that you sometimes want the opposite, and the escape hatch is named rather than hidden behind a flag on the command itself.

The same philosophy shows up in the pipeline example, where reading the output of a failed second command would also be an error, and in the reader example, where the error from a child that fails mid-stream is the error you get rather than a truncated output.

The Rust type system carries the error, since every one of these calls returns a Result, so the question of whether you handle a failed child is a compile-time question rather than a code review question.

## The dependency list is small and tells you the shape

The Cargo manifest is short, and each dependency has an obvious job.

The crate is named duct, at version 1.1.2, edition 2021, MIT licensed, with the author as oconnor663. The runtime dependencies are os_pipe for the pipe plumbing, shared_child for the child process side, and shared_thread.

The features section has one entry and it is worth reading carefully. The default features are timeout, and the timeout feature enables shared_child/timeout. So a timeout on child processes is on by default, which is a defensible default for a library whose whole purpose is running other programs: a child that never exits should not hang your program forever.

On Unix, the target-specific dependency is libc, which is how the process group and signal handling are done where the platform offers them. That conditional dependency is the visible edge of the platform differences the README claims to handle, and it is also the reason the crate behaves differently on Windows, where the equivalent job is done with the native APIs.

The dev-dependency is tempfile, which is what the test suite uses for temporary files and directories, and the test suite is run in CI through the workflow the badge at the top of the README points at.

There is nothing else. A process library that pulls in a serialization framework or an async runtime would be doing more than the job requires, and the absence of both is part of why the crate is a reasonable dependency to add to something small.

## duct_sh, the Python sibling, and the gotchas list

Two things in the repository point at how the project is organised.

The first is the duct_sh/ directory at the root, alongside src/. The crate itself is the duct package, and a separate shell-like layer sits beside it, which is how the library offers a shell-shaped API on top of the list-of-arguments API. The README's bullet list of links names the same library in Python, duct.py, and the gotchas link in the main description points into that Python repository for the document listing the platform inconsistencies and bugs that are handled for you.

That is a design decision worth noting. The same author's Python library and this Rust library share a conceptual model, and the document explaining the sharp edges lives in the Python repository. It means the Rust crate inherits a set of behaviours that were designed and debugged against a different language, which is either a strength or a curiosity depending on your taste.

The second is README.tpl at the root. A template file next to a README usually means the README is generated, which in this case fits a project whose documentation is shared between the two implementations.

The documentation itself lives on docs.rs rather than in the repository, and the crate page carries the version badge from crates.io. So the model is: the code and the small examples live in the repository, and the reference documentation is generated from the source comments.

## What the project does not promise

The README is short, and what it leaves out is as informative as what it says.

There is no mention of an asynchronous API, and the dependency list confirms that. There is no shell expansion, no environment variable interpolation and no glob, because the command is a list of arguments and the library is not a shell. There is no promise about output encoding, since the child writes bytes and duct reads them.

That set of omissions is the point of the library rather than gaps in it. A crate that ran everything through a shell would be able to do more, and would also reintroduce every quoting bug and every difference between how a shell and a process API handle a path with a space.

The one capability that looks like a shell feature is the pipe, and it is deliberately narrow: you pipe one duct command into another, and both ends keep the argument and error semantics of the crate.

The maintenance picture is quiet but healthy. The last push was on 2026-09-20, the repository is not archived, and the crate is at 1.1.2. There are no GitHub releases, so the version history lives on crates.io and in the changelog that the project keeps outside the release list, and the CI badge in the README is the indicator to watch if you depend on it.

## Conclusion

Adopt duct if you spawn child processes in Rust and have ever been bitten by a command string with a space in a filename, a child that failed silently, or a pipe that deadlocked on Windows. Do not adopt it if you need the full shell, because the point of the library is that it is not a shell, and do not skip the default error behaviour without understanding it, since a non-zero exit is an error by default and unchecked() is how you say you meant it. Before you start, read the gotchas document the README links, and keep the default timeout feature enabled if your children can hang, since it is on by default but easy to lose when features are pinned in a workspace.

## FAQ

### What is duct and what problem does it solve?

It is a Rust library for running child processes that makes pipelines and IO redirection easy like a shell, while keeping the code correct and portable: whitespace in a command is never significant, errors from child processes are reported by default, and platform inconsistencies are handled inside the crate.

### How do I run a command and capture its output in duct?

Use cmd!("echo", "hi").read() to get the standard output as a String, or cmd!("echo", "hi").run() to run it without capturing anything so the output goes straight to the terminal. For a pipeline, pipe one command into another, for example cmd!("echo", "hi").pipe(cmd!("sed", "s/i/o/")).read().

### What happens when a child process exits with a non-zero status in duct?

The call returns an error, so a failed child cannot be ignored by accident. You can opt out with the unchecked call, so cmd!("false").unchecked().run() succeeds where cmd!("false").run() returns an error.

### Does duct support timeouts and merging stderr into stdout?

Yes. The timeout feature is in the default feature set and enables the shared_child timeout support, and stderr_to_stdout() merges the error stream into the output stream, with reader() giving you an incremental reader so a long-running child's output can be streamed rather than buffered.

### What does duct depend on?

Three runtime crates: os_pipe for the pipe plumbing, shared_child for the child process side and shared_thread for shared state, plus libc on Unix. The default feature is timeout, the crate is MIT licensed, and the dev dependency for tests is tempfile.

## Sources

- [Issues](https://github.com/oconnor663/duct.rs/issues)
- [License: MIT](https://github.com/oconnor663/duct.rs/blob/master/LICENSE)
- [oconnor663/duct.rs on GitHub](https://github.com/oconnor663/duct.rs)
- [README](https://github.com/oconnor663/duct.rs/blob/master/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/oconnor663-duct-rs
