Dart Process
last modified May 29, 2026
The Process class in Dart provides functionality to run and
interact with system processes. It is part of Dart's dart:io
library and is available in command-line and server-side applications.
Process allows executing external commands, reading their output, writing to their stdin, monitoring exit codes, and terminating processes with signals. It is essential for system-level programming, build tooling, and shell automation in Dart.
Basic Definition
Process represents a native OS process started by the Dart VM.
The two primary entry points are Process.start and
Process.run. Choose based on your use case:
| Feature | Process.start | Process.run |
|---|---|---|
| Returns | Future<Process> | Future<ProcessResult> |
| Output handling | Streaming — chunks arrive as data | Buffered — all output collected in memory |
| Blocks until exit | No — returns immediately | Yes — awaits process completion |
| stdin access | Full writable stream | Not accessible after start |
| Memory use | Constant — streams are not buffered | Proportional to total output size |
| Best for | Long-running, interactive, large output | Short commands, simple scripts |
Both methods are asynchronous and return a Future.
Use ProcessSignal to send signals such as
SIGTERM or SIGKILL to a running process.
Running a Simple Command
This example starts an external command with Process.start and
streams its output.
import 'dart:io';
import 'dart:convert';
void main() async {
var process = await Process.start('ls', ['-l']);
await process.stdout.transform(utf8.decoder).forEach(print);
var exitCode = await process.exitCode;
print('Exit code: $exitCode');
}
Process.start launches the command and returns a
Process object immediately. The stdout property
is a byte stream; we decode it with utf8.decoder and print
each chunk. Awaiting process.exitCode blocks until the process
terminates and yields its numeric exit code (0 means success).
$ dart main.dart total 4 -rw-r--r-- 1 user user 241 May 29 10:00 main.dart Exit code: 0
Handling Input and Output
This example demonstrates two-way communication with a process through its
stdin and stdout streams.
import 'dart:io';
import 'dart:convert';
void main() async {
var process = await Process.start('grep', ['--color=never', 'dart']);
// Write lines to the process stdin
process.stdin.writeln('This line contains dart');
process.stdin.writeln('This line does not match');
process.stdin.writeln('Another dart reference here');
await process.stdin.close(); // signal EOF so grep can finish
// Collect matching lines from stdout
var output = await process.stdout.transform(utf8.decoder).join();
print(output.trim());
await process.exitCode;
}
We pipe text into grep's stdin and collect only the
lines that match the pattern from stdout. Calling
stdin.close() sends an EOF signal, which tells the child process
that no more input is coming. Without it, grep would wait forever.
$ dart main.dart This line contains dart Another dart reference here
Capturing stdout and stderr Simultaneously
When a process writes to both stdout and stderr,
you must listen to both streams concurrently. Subscribing to one stream
and then the other risks a deadlock: if the process fills the unread pipe
buffer it will stall, and your code never reaches the second
listen call.
import 'dart:io';
import 'dart:convert';
void main() async {
var process = await Process.start(
'sh', ['-c', 'echo "normal output"; echo "error output" 1>&2; exit 1'],
);
// Subscribe to both streams before awaiting either
process.stdout
.transform(utf8.decoder)
.listen((data) => print('OUT: ${data.trim()}'));
process.stderr
.transform(utf8.decoder)
.listen((data) => print('ERR: ${data.trim()}'));
final code = await process.exitCode;
print('Exit: $code');
}
Both listen calls register non-blocking callbacks that fire
whenever data arrives. Only after both are subscribed do we await
exitCode. The event loop services both pipes in parallel so
neither buffer fills up.
OUT: normal output ERR: error output Exit: 1
Avoiding Deadlocks
Deadlocks occur when your code waits for the process to finish while the process is blocked waiting for a reader to consume its output. Common pitfalls:
- Awaiting
exitCodebefore drainingstdoutorstderr— the child fills the pipe buffer and stalls. - Reading only
stdoutwhile the child also writes tostderr— the unreadstderrbuffer fills and the child stalls. - Calling
stdout.toList()andstderr.toList()sequentially — the second await deadlocks if the first stream does not drain fully while the other buffer is full.
import 'dart:io';
import 'dart:convert';
void main() async {
var process = await Process.start(
'sh', ['-c', 'for i in $(seq 1 1000); do echo "line $i"; done'],
);
// Start both consumers before awaiting either result
final stdoutFuture = process.stdout.transform(utf8.decoder).join();
final stderrFuture = process.stderr.transform(utf8.decoder).join();
// Wait for both streams, then check exit code
final results = await Future.wait([stdoutFuture, stderrFuture]);
final exitCode = await process.exitCode;
print('Lines : ${results[0].trim().split('\n').length}');
print('Errors: "${results[1]}"');
print('Exit : $exitCode');
}
join() returns a Future<String> that resolves
when the stream closes. Using Future.wait lets the event loop
drain both pipes concurrently, so neither buffer stalls the child. Only
after both futures resolve — meaning both streams are fully consumed — do
we await the exit code.
Lines : 1000 Errors: "" Exit : 0
Piping Processes Together
Dart has no built-in shell pipe operator, but you can connect two processes
by forwarding the stdout of the first into the
stdin of the second using Stream.pipe.
import 'dart:io';
import 'dart:convert';
void main() async {
// Equivalent of: ls -1 /usr/bin | grep 'zip'
var p1 = await Process.start('ls', ['-1', '/usr/bin']);
var p2 = await Process.start('grep', ['zip']);
// Wire p1 stdout → p2 stdin; pipe() closes p2.stdin when p1.stdout ends
p1.stdout.pipe(p2.stdin);
// Drain p1 stderr to prevent it from blocking
p1.stderr.drain<void>();
final output = await p2.stdout.transform(utf8.decoder).join();
await Future.wait([p1.exitCode, p2.exitCode]);
print(output.trim());
}
Stream.pipe forwards every chunk from the source stream to the
given StreamSink and automatically closes the sink when the
source ends. This sends an EOF to grep's stdin, letting it
flush its output and exit cleanly. Always drain the stderr of any process
whose stdout you are piping to avoid a secondary deadlock.
bunzip2 bzip2 gzip unzip zip zipgrep
Collecting Output with Process.run
Process.run is a convenience wrapper around
Process.start that waits for the process to exit and
buffers all output. It is ideal for short commands where you only care
about the final result.
import 'dart:io';
void main() async {
var result = await Process.run('date', ['+%Y-%m-%d %H:%M:%S']);
if (result.exitCode == 0) {
print('Date: ${result.stdout.trim()}');
} else {
print('Error: ${result.stderr}');
exitCode = result.exitCode;
}
}
Process.run returns a ProcessResult containing
stdout, stderr, and exitCode. Both
output properties are strings when stdoutEncoding is not
explicitly set to null. Checking exitCode is good
practice before trusting the output.
$ dart main.dart Date: 2026-05-29 10:32:11
ProcessResult and Large Output
Process.run accumulates the entire output in memory before
returning. This is convenient for small commands but becomes a problem when
output is large — for example find / or a long-running build
log. Use Process.start and process the stream line by line
when the output size is unbounded.
import 'dart:io';
import 'dart:convert';
void main() async {
// Process.run buffers everything — risky for large output
// var result = await Process.run('find', ['/']);
// Process.start streams output — safe for any size
var process = await Process.start('find', ['/usr', '-name', '*.dart']);
process.stderr.drain<void>(); // discard stderr without blocking
var count = 0;
await process.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.forEach((_) => count++);
await process.exitCode;
print('Found $count .dart files');
}
LineSplitter splits the decoded text into individual lines so
each line can be acted on without buffering the full output. Memory use
stays constant regardless of how many lines the command produces.
Use Process.run for commands that produce at most a few MB;
switch to Process.start for anything larger.
$ dart main.dart Found 1842 .dart files
Handling Errors
Two distinct failure modes exist: the process cannot be launched at all
(throws ProcessException), and the process runs but exits with
a non-zero code (check exitCode and stderr).
import 'dart:io';
void main() async {
// Case 1: executable not found
try {
await Process.run('nonexistent_cmd', []);
} on ProcessException catch (e) {
print('Could not start process: ${e.message} (errno ${e.errorCode})');
}
// Case 2: process starts but fails
var result = await Process.run('ls', ['/no/such/directory']);
if (result.exitCode != 0) {
print('Command failed (exit ${result.exitCode}): ${result.stderr.trim()}');
}
}
ProcessException is thrown when the OS cannot locate or start the
executable. It carries a human-readable message and a numeric
errorCode. A non-zero exitCode is the conventional
way programs signal failure after a successful start; always read
stderr in that case for the diagnostic message.
$ dart main.dart Could not start process: No such file or directory (errno 2) Command failed (exit 2): ls: cannot access '/no/such/directory': No such file or directory
Understanding StdioType
When Dart spawns a child process, it creates OS-level pipes for
stdout and stderr so that your Dart code can read
from them as streams. The child process therefore does not see a terminal
(TTY) on those descriptors — it sees the write end of a pipe. This is why
programs that detect whether their output goes to a terminal (for example,
to decide whether to enable ANSI colours or buffering) will behave
differently when started from Dart.
The StdioType enum from dart:io describes what a
file descriptor actually is: StdioType.terminal,
StdioType.pipe, or StdioType.other. The
top-level stdioType() function returns the type for a given
stream.
import 'dart:io';
void main() async {
// Report what the parent process sees
print('Parent stdin : ${stdioType(stdin)}');
print('Parent stdout : ${stdioType(stdout)}');
print('Parent stderr : ${stdioType(stderr)}');
// Spawn a child that reports its own stdio types
var result = await Process.run('dart', ['child.dart']);
print('\nChild reports:\n${result.stdout.trim()}');
}
import 'dart:io';
void main() {
print('stdin : ${stdioType(stdin)}');
print('stdout : ${stdioType(stdout)}');
print('stderr : ${stdioType(stderr)}');
}
Even when the parent's stdout is a terminal, the child's stdout and stderr
are reported as StdioType.pipe because Dart wired OS pipes
between parent and child. The child's stdin is reported as
StdioType.other — it is also a pipe, but the read end rather
than the write end.
Parent stdin : StdioType.terminal Parent stdout : StdioType.terminal Parent stderr : StdioType.terminal Child reports: stdin : StdioType.other stdout : StdioType.pipe stderr : StdioType.pipe
The practical consequence is that tools like ls --color,
grep --color=auto, and many CLI programs will suppress colour
output when called from Dart. Pass an explicit flag (e.g.
--color=always) or strip ANSI codes from the output if you
need plain text.
Running Multiple Processes Concurrently
This example launches several independent processes at the same time and
collects their results with Future.wait.
import 'dart:io';
void main() async {
var futures = [
Process.run('uname', ['-s']),
Process.run('date', ['+%Y-%m-%d']),
Process.run('hostname', []),
];
var results = await Future.wait(futures);
for (var result in results) {
if (result.exitCode == 0) {
print(result.stdout.trim());
} else {
print('Error: ${result.stderr.trim()}');
}
}
}
Future.wait takes a list of futures and resolves when every
one of them completes. The calls are started concurrently, not sequentially,
so the total wall-clock time is roughly that of the slowest command. The
results list preserves the original order regardless of which process
finishes first.
$ dart main.dart Linux 2026-05-29 workstation
Setting Working Directory
The optional workingDirectory parameter controls which directory
the child process sees as its current working directory. This is useful when
a command must be run from a specific location, for example a build tool that
expects its project files nearby.
import 'dart:io';
void main() async {
final tmpDir = Directory.systemTemp.path;
// Create a temporary file so 'ls' has something to show
final file = File('$tmpDir/dart_demo.txt');
await file.writeAsString('hello from Dart');
var result = await Process.run(
'ls',
['-lh', 'dart_demo.txt'],
workingDirectory: tmpDir,
);
print(result.stdout.trim());
print('Exit code: ${result.exitCode}');
await file.delete();
}
Without workingDirectory, relative paths in command arguments
are resolved against the Dart script's working directory. Providing an
explicit path makes behaviour predictable regardless of where the script
is invoked from. The value must be an absolute path to an existing directory.
$ dart main.dart -rw-r--r-- 1 user user 15 May 29 10:42 dart_demo.txt Exit code: 0
Setting Environment Variables
The environment parameter passes a custom set of environment
variables to the child process. By default the child inherits the parent's
environment; supplying this map replaces it entirely unless you merge it
with Platform.environment.
import 'dart:io';
void main() async {
final env = {
...Platform.environment, // inherit existing env
'APP_ENV': 'production',
'APP_VERSION': '2.1.0',
'LOG_LEVEL': 'warn',
};
var result = await Process.run(
'printenv',
['APP_ENV', 'APP_VERSION', 'LOG_LEVEL'],
environment: env,
);
print(result.stdout.trim());
print('Exit code: ${result.exitCode}');
}
Spreading Platform.environment first preserves PATH
and other variables needed for the command to run correctly, then the custom
entries override or extend them. This pattern is common for configuring
deployment environments, injecting secrets, or varying runtime behaviour
without touching config files.
$ dart main.dart production 2.1.0 warn Exit code: 0
Killing a Process
Long-running processes sometimes need to be aborted. The
process.kill method sends a POSIX signal; the default is
ProcessSignal.sigterm, which asks the process to shut down
gracefully. Use ProcessSignal.sigkill if it does not respond.
import 'dart:io';
void main() async {
var process = await Process.start('sleep', ['60']);
print('Started process PID: ${process.pid}');
// Simulate a timeout: kill after 2 seconds
final timer = Future.delayed(const Duration(seconds: 2), () {
final sent = process.kill(ProcessSignal.sigterm);
print('SIGTERM sent: $sent');
});
await timer;
final code = await process.exitCode;
print('Process exited with code: $code');
}
process.kill returns true if the signal was
delivered and false if the process had already exited.
On Linux/macOS, a process terminated by SIGTERM typically exits with code
-15; one killed by SIGKILL exits with -9. Always
await process.exitCode after killing to release the OS resources
associated with the process.
$ dart main.dart Started process PID: 18432 SIGTERM sent: true Process exited with code: -15
Timeout Handling
A process that hangs can block your program indefinitely. Use the
timeout extension on Future to set a deadline and
kill the process if it is exceeded.
import 'dart:io';
import 'dart:async';
void main() async {
var process = await Process.start('sleep', ['30']);
print('PID: ${process.pid}');
final code = await process.exitCode.timeout(
const Duration(seconds: 3),
onTimeout: () {
print('Timed out — killing process');
process.kill(ProcessSignal.sigkill);
return -1; // synthetic exit code returned to the caller
},
);
print('Exit code: $code');
}
Future.timeout fires onTimeout when the deadline
expires without the future resolving. Using sigkill guarantees
termination; sigterm only requests it and the process may
ignore it. The onTimeout callback returns a synthetic exit
code so the rest of the program can continue normally.
PID: 24871 Timed out — killing process Exit code: -1
Launching GUI Applications
Process.start can open GUI applications just like any other
executable. Because the launched app has its own window and event loop,
there is normally no need to read its output or wait for it to exit.
The example below detects the current platform and opens a ubiquitous
text editor on each OS.
import 'dart:io';
void main() async {
if (Platform.isWindows) {
// Notepad - built into every Windows installation
await Process.start('notepad.exe', [], mode: ProcessStartMode.detached);
} else if (Platform.isMacOS) {
// TextEdit - the default plain-text editor on macOS
await Process.start('open', ['-a', 'TextEdit'],
mode: ProcessStartMode.detached);
} else if (Platform.isLinux) {
// Try common editors in order; fall back gracefully
final editors = ['gedit', 'kate', 'mousepad', 'xed'];
bool launched = false;
for (final editor in editors) {
try {
await Process.start(editor, [],
mode: ProcessStartMode.detached);
print('Opened $editor');
launched = true;
break;
} on ProcessException {
print('Could not launch $editor, trying next...');
continue; // editor not installed, try next
}
}
if (!launched) print('No known text editor found.');
}
}
ProcessStartMode.detached tells the OS to decouple the child
from the Dart process: the editor stays open even after the Dart program
exits, its stdout/stderr are not connected to the parent, and the Dart
program does not need to await its exit code. On macOS, the open
-a idiom delegates to the system launcher, which resolves the
correct application bundle automatically.
Reading Binary Output
Some tools produce binary data — compressed archives, raw checksums, image bytes. Read the raw byte stream directly instead of passing it through a text decoder, which would corrupt the data.
import 'dart:io';
import 'dart:typed_data';
void main() async {
// Compress a string via gzip and capture the raw binary output
var process = await Process.start('gzip', ['-c']);
process.stdin.write('Hello, Dart!');
await process.stdin.close();
// Collect raw byte chunks — do not use utf8.decoder here
final chunks = <List<int>>[];
await for (final chunk in process.stdout) {
chunks.add(chunk);
}
await process.exitCode;
final bytes = Uint8List.fromList(chunks.expand((c) => c).toList());
print('Compressed ${bytes.length} bytes');
// gzip files always start with magic bytes 0x1f 0x8b
print('Magic: 0x${bytes[0].toRadixString(16)}${bytes[1].toRadixString(16)}');
}
process.stdout is a Stream<List<int>>
that yields raw byte buffers. Iterating it with await for
collects every chunk without any text decoding. Flattening with
expand and wrapping in Uint8List produces a
contiguous byte array ready for file writes or further processing.
Compressed 28 bytes Magic: 0x1f8b
Windows vs Linux/macOS Differences
Dart's Process API is cross-platform, but the underlying
commands and shell conventions differ. The most important differences to
keep in mind:
- Shell built-ins (
dir,echo,type) are not standalone executables on Windows; wrap them withcmd /c. - Path separators: Windows uses
\; usePlatform.pathSeparatoror thepathpackage for portable path construction. - Signals: SIGTERM and SIGKILL are not meaningful on Windows;
process.kill()terminates the process viaTerminateProcessregardless of the signal enum value. - Executables: Windows requires the
.exeextension for programs not on PATH; scripts need an explicit interpreter (python script.pyrather than./script.py). - Line endings:
stdouton Windows may contain\r\n; calloutput.replaceAll('\r\n', '\n')when cross-platform text comparison is needed.
import 'dart:io';
Future<ProcessResult> listFiles(String dir) {
if (Platform.isWindows) {
return Process.run('cmd', ['/c', 'dir', dir]);
} else {
return Process.run('ls', ['-l', dir]);
}
}
void main() async {
final result = await listFiles('.');
if (result.exitCode == 0) {
print(result.stdout);
} else {
print('Error: ${result.stderr}');
}
}
Using Platform.isWindows / Platform.isLinux /
Platform.isMacOS is the standard way to branch on the host OS.
For maximum portability, isolate all platform-specific command strings
behind helper functions like the one above.
Real-World Example: Running git
This example runs git log to retrieve the five most recent
commit messages, processes the output line by line, and prints a structured
summary. It combines working directory control, environment passthrough,
and streaming output handling in a single realistic script.
import 'dart:io';
import 'dart:convert';
void main() async {
final repoDir = Directory.current.path;
final process = await Process.start(
'git',
['log', '--oneline', '-5', '--no-color'],
workingDirectory: repoDir,
environment: {
...Platform.environment,
'GIT_TERMINAL_PROMPT': '0', // disable interactive credential prompts
},
);
// Drain stderr in the background so it never blocks the process
process.stderr.drain<void>();
final lines = await process.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.toList();
final code = await process.exitCode;
if (code != 0) {
print('git failed (exit $code). Is this a git repository?');
return;
}
print('Recent commits in: $repoDir');
for (final line in lines) {
final spaceIndex = line.indexOf(' ');
final hash = line.substring(0, spaceIndex);
final message = line.substring(spaceIndex + 1);
print(' [$hash] $message');
}
}
process.stderr.drain() discards stderr output while still
consuming it, preventing a deadlock if git writes warnings.
GIT_TERMINAL_PROMPT=0 prevents git from hanging
the script waiting for credentials when run against a private remote.
Recent commits in: /home/user/myproject [a1b2c3d] Fix null safety issue in process handler [e4f5a6b] Add timeout support to process runner [c7d8e9f] Refactor stream handling [1a2b3c4] Update README [9f8e7d6] Initial commit
Best Practices
- Always listen to both stdout and stderr: Subscribe to
both streams before awaiting
exitCodeto prevent deadlocks caused by a full pipe buffer. - Use Process.start for large or long-running output:
Process.runbuffers everything in memory; switch toProcess.startwith streaming when output size is unbounded. - Close stdin when done writing: Call
process.stdin.close()to send EOF; otherwise programs likegreporcatwill wait forever for more input. - Set a timeout for untrusted processes: Use
exitCode.timeout(...)and kill the process inonTimeoutto avoid hanging indefinitely. - Sanitize arguments: Never interpolate untrusted strings directly into a command; pass them as separate argument list entries so the OS does not interpret shell metacharacters.
- Drain stderr even when you don't need it: Call
process.stderr.drain()to prevent the stderr buffer from filling and stalling the child process. - Use Platform checks for cross-platform scripts: Branch
on
Platform.isWindows/Platform.isLinuxto select the correct command and shell wrapping per OS. - Use ProcessStartMode.detached for background daemons: The child survives Dart process exit and its streams are not connected to the parent.
Source
This tutorial covered Dart's Process class with practical examples showing command execution, I/O handling, deadlock avoidance, process piping, timeout handling, binary output, cross-platform differences, and real-world usage.
Author
List all Dart tutorials.