External Processes

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14, on macOS, so the commands are POSIX ones such as echo, sort and sh). The GDK adds execute() to String, List and String[]. It starts an operating-system process and returns a standard java.lang.Process, and a set of helper methods on Process make the awkward parts (draining output, piping, timeouts) one-liners. The examples use only harmless commands, read and write only inside a scratch directory work, and never use a shell unless they say so.

Running a command

'cmd arg'.execute() splits the string on whitespace and starts the program; ['cmd', 'arg'].execute() uses each list element as one argument. The returned Process gives its standard output as in (the stream you read), its standard input as out (the stream you write to) and its standard error as err. text reads the standard output until it closes, and waitFor() blocks until the process ends and returns its exit value. A command that cannot be started at all, such as one that does not exist, throws an IOException from execute().

def proc = 'echo hello from a child'.execute()
println proc.class.name
print proc.text                        // reads stdout until it closes
println proc.waitFor()                 // exit value

def args = ['echo', 'two words', 'x'].execute()   // a List keeps each element as one argument
println args.text.trim()

def failing = 'ls /definitely/not/here'.execute()
failing.waitFor()
println failing.exitValue()
println failing.err.text.trim().contains('No such file')
println 'true'.execute().waitFor()
println 'false'.execute().waitFor()
try { 'no-such-command-xyz'.execute() } catch (IOException e) { println e.class.simpleName }
java.lang.ProcessImpl
hello from a child
0
two words x
1
true
0
1
IOException

A non-zero exit value is not an exception; check exitValue() (or the ok property of the result in the section on waitForResult below) yourself.

Quoting and the shell

execute() does not start a shell. The consequences are the usual surprises:

  • A String command is split on whitespace only, so quote characters are not interpreted. They reach the program as part of its arguments.

  • There is no expansion of variables, globs or ~, and no |, > or && of the shell.

  • Interpolating a value into a string command ("ls ${dir.path}".execute()) splits it again at every space inside the value.

  • Shell built-ins (dir on Windows, cd, source) are not programs. On Windows the DOS dir needs cmd /c dir.

def one = 'printf "b\\na\\n"'.execute()          // a String is split on spaces: the quotes reach printf
println one.text.readLines()
def two = ['printf', 'b\na\n'].execute()          // a List keeps arguments intact
println two.text.readLines()
println 'echo $HOME'.execute().text.trim()        // no shell, so nothing is expanded
println 'echo *'.execute().text.trim()
println(['sh', '-c', 'echo "$((6 * 7))"'].execute().text.trim())   // ask a shell explicitly
def dir = new File('work/my beds')
dir.mkdirs()
new File(dir, 'a.txt').text = 'x'
def bad = "ls ${dir.path}".execute()             // the space splits the path in two arguments
bad.waitFor()
println bad.exitValue()
println(['ls', dir.path].execute().text.trim())
["b, a, "]
[b, a]
$HOME
*
42
1
a.txt

The first line shows the quotes as data: the command was split into the program and one argument that still contains its quote characters, so printf printed them ("b, a, "). Prefer the List form for anything with a variable part. When a real shell is wanted for a pipeline or a redirect, say so with ['sh', '-c', '...'], and never build that string from untrusted input: it is a command-injection vector. With the List form and no shell there is nothing to inject into.

Draining the output

A child that writes more than the operating system’s pipe buffer blocks until somebody reads, and a parent that waits for the child without reading deadlocks. The JDK documentation of Process warns about this, and the GDK helpers exist to avoid it:

  • consumeProcessOutput() discards both output streams in background threads; consumeProcessOutput(out, err) collects them into any Appendable (a StringBuilder) or OutputStream.

  • waitForProcessOutput() and waitForProcessOutput(out, err) do the same and then wait for the process to end.

  • consumeProcessOutputStream and consumeProcessErrorStream handle one stream each and return the thread.

def p = ['sh', '-c', 'echo out; echo err 1>&2; exit 3'].execute()
def out = new StringBuilder(), err = new StringBuilder()
p.consumeProcessOutput(out, err)       // drains both pipes in background threads
p.waitFor()
println "exit=${p.exitValue()} out=${out.toString().trim()} err=${err.toString().trim()}"

def q = ['sh', '-c', 'echo a; echo b 1>&2'].execute()
def o2 = new StringBuilder(), e2 = new StringBuilder()
q.waitForProcessOutput(o2, e2)         // consume both streams and wait for exit
println([o2.toString().trim(), e2.toString().trim()])

def lines = []
['printf', 'x\ny\n'].execute().in.eachLine { lines << it }   // 'in' is the child's stdout
println lines
exit=3 out=out err=err
[a, b]
[x, y]

Pipes

The | operator maps to pipeTo: it connects the standard output of the left process to the standard input of the right one and returns the right one, so a chain is read from its last element. Groovy 6.0 adds pipeline() on a list of commands, which starts them all connected and returns the list of processes.

def sorted = ['printf', 'b\na\nc\n'].execute() | ['sort'].execute()
println sorted.text.readLines()

def procs = [['printf', 'x\ny\nx\n'], ['sort'], ['uniq', '-c']].pipeline()
println procs.size()
println procs.last().text.readLines()*.trim()

def cat = ['cat'].execute()
cat.withWriter { it << 'via the writer\n' }       // closing the writer ends stdin
println cat.text.trim()
def srt = ['sort'].execute()
srt << 'pear\napple\n'
srt.out.close()
println srt.text.readLines()
[a, b, c]
3
[2 x, 1 y]
via the writer
[apple, pear]

To feed text to a child, write to out: process.withWriter { ... } and process.withOutputStream { ... } close the stream afterwards, which is the signal to programs such as sort and cat that input has ended. With << the stream stays open until you close it (srt.out.close() above), and a reader that waits for end of input would otherwise wait forever.

Timeouts, completion callbacks and ProcessBuilder

waitForOrKill(millis) waits that long and destroys the process if it is still running. waitFor(time, unit) is the JDK’s version that returns false instead of killing. onExit() is the JDK’s method (Java 9 and later) and returns a CompletableFuture<Process> that completes when the child ends; the GDK adds an overload that takes a closure, onExit { proc -> ... }, among the process additions listed in the Groovy 6.0 release notes. toProcessBuilder() turns a command (String, String[] or List) into a ProcessBuilder when the JDK API is needed for options such as redirects.

import java.util.concurrent.TimeUnit
def slow = ['sleep', '5'].execute()
slow.waitForOrKill(300)               // destroys the process after 300 ms
println slow.waitFor()                // 143 = 128 + SIGTERM
def p = ['sleep', '5'].execute()
println p.waitFor(300, TimeUnit.MILLISECONDS)   // the JDK method: false means still running
p.destroy()
def r = ['sleep', '5'].execute().waitForResult(300, TimeUnit.MILLISECONDS)
println r

def f = ['echo', 'async'].execute().onExit()      // CompletableFuture<Process>
println f.get().exitValue()
def pb = ['sh', '-c', 'echo $0 $1', 'a', 'b'].toProcessBuilder()
println pb.class.simpleName
pb.redirectErrorStream(true)
println pb.start().text.trim()
143
false
ProcessResult(exitCode=137, out="", err="")
0
ProcessBuilder
a b

The exit value 143 is the shell convention for a process ended by SIGTERM, and 137 for SIGKILL; both are Unix conventions, and on Windows a destroyed process reports a different value.

Options with named parameters (Groovy 6.0)

Groovy 6.0 lets execute take a map of options in place of the older execute(envp, dir) signature: dir is the working directory, env a map of variables added to the inherited environment, redirectErrorStream: true merges standard error into standard output, inheritIO: true shares the parent’s console, and outputFile, errorFile and inputFile redirect to and from files, with appendOutput and appendError taking a file to append to. waitForResult() (also Groovy 6.0) waits and returns a ProcessResult (in org.codehaus.groovy.runtime) with out, err, exitCode and ok, collecting both streams for you.

def work = new File('work')
println(['pwd'].execute(dir: work).text.trim().endsWith('/work'))
println(['sh', '-c', 'echo $GREENHOUSE_BED'].execute(env: [GREENHOUSE_BED: 'bed7']).text.trim())
println(['sh', '-c', 'echo "${HOME:+HOME kept} $GREENHOUSE_BED"'].execute(env: [GREENHOUSE_BED: 'bed7']).text.trim())
println(['sh', '-c', 'echo "${HOME:+HOME kept} $GREENHOUSE_BED"'].execute(['GREENHOUSE_BED=bed7'], null).text.trim())
def merged = ['sh', '-c', 'echo out; echo err 1>&2'].execute(redirectErrorStream: true)
println merged.text.readLines().sort()
def out = new File('work/out.txt')
out.delete()
['echo', 'to a file'].execute(outputFile: out).waitFor()
['echo', 'more'].execute(appendOutput: out).waitFor()
println out.readLines()
def ins = new File('work/in.txt'); ins.text = 'c\nb\na\n'
println(['sort'].execute(inputFile: ins).text.readLines())
def res = ['sh', '-c', 'echo out; echo err 1>&2; exit 2'].execute().waitForResult()
println res
println([res.out.trim(), res.err.trim(), res.exitCode, res.ok])
println(['true'].execute().waitForResult().ok)
def errFile = new File('work/err.txt'); errFile.delete()
['sh', '-c', 'echo oops 1>&2'].execute(errorFile: errFile).waitFor()
println errFile.readLines()
true
bed7
HOME kept bed7
bed7
[err, out]
[to a file, more]
[a, b, c]
ProcessResult(exitCode=2, out="out", err="err")
[out, err, 2, false]
true
[oops]

The third and fourth lines show a difference worth remembering: with env: the child still had HOME, while the older list form execute(['GREENHOUSE_BED=bed7'], null) replaced the whole environment, so HOME was gone. outputFile truncates, while appendOutput and appendError append.

Pitfalls

  • Reading text before waitFor() is safe for small output because text waits for end of stream; waiting first and reading afterwards can deadlock on large output. Use waitForProcessOutput when both are needed.

  • Reading only standard output while the child fills standard error (or the other way round) can also deadlock; drain both, or merge them with redirectErrorStream: true.

  • A println with a leading list literal, such as println ['true'].execute(), is parsed as an index into a property named println and fails with No such property: println. Write println(['true'].execute()).

  • The commands differ between operating systems; code that must run on Windows and Unix has to branch or avoid the shell.

  • Do not run commands built from untrusted input, and never splice such input into a sh -c string.

Where the book is outdated

Books from the Groovy 1.x and 2.x era describe execute(), waitFor and consumeProcessOutput and the | operator, which are unchanged, but they know none of the Groovy 6.0 additions: pipeline(), onExit, toProcessBuilder, waitForResult with ProcessResult and the named-parameter execute(dir:, env:, ...). They also predate the JDK’s Process.onExit(), pid() and ProcessHandle, which are plain Java and available from Groovy.