Files & Processes with OS-Lib

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 Scala 3.10.0 (released 2026-10-12, the current Scala Next line) on JDK 26, with the Scala Toolkit 0.9.2, which contains OS-Lib 0.11.8. Every program below was run with Scala CLI 1.17.1 on macOS on 2026-10-16, so the process examples use POSIX commands such as ls, sort and sh; Windows was not tested, and the process examples would need other commands there. OS-Lib is not part of the Scala standard library: it is a third-party library by Li Haoyi that is bundled into the official Scala Toolkit, and the file and process programs below gave the same output when run with 3.3.8 (3.3 LTS), 3.9.0 (3.9 LTS) and 3.10.0. The standard library alternatives (java.nio.file, scala.io.Source and scala.sys.process) are covered at the end of the page.

OS-Lib aims to replace java.nio.file and java.lang.ProcessBuilder for everyday scripting: all of it lives in the os package, it has no dependencies, and its functions return plain Scala values (paths, strings, sequences of lines) instead of streams you have to close. The Java counterparts are described on Java I/O & Files, and the Groovy ones on Files, I/O & Paths and External Processes. The other Toolkit libraries are on Testing with MUnit, JSON with uPickle and HTTP Client with sttp.

Getting OS-Lib

With Scala CLI (see Scala CLI) one directive adds the whole Toolkit, or just OS-Lib:

//> using toolkit 0.9.2
// or only this library, at the version the Toolkit 0.9.2 pins:
//> using dep com.lihaoyi::os-lib:0.11.8

In build.sbt the same two choices are:

libraryDependencies += "org.scala-lang" %% "toolkit" % "0.9.2"
// or
libraryDependencies += "com.lihaoyi" %% "os-lib" % "0.11.8"

The first run resolves the artifacts from Maven Central through Coursier; later runs use the local cache. Both forms were used for the programs below. A .sc script needs only the directive and the code, with no @main:

//> using toolkit 0.9.2
println(os.pwd.last)
println(os.proc("echo", "script").call().out.trim())
os-lib
script

(The first line is the name of the directory the script was run from.) The examples that follow use a small trailhead scenario: a folder of route notes that a program lists, edits and passes to other commands.

Paths

OS-Lib distinguishes three kinds of path, and the compiler keeps them apart:

Type Meaning

os.Path

An absolute path, always normalized (no . or .. segments left). Every function that touches the disk takes one.

os.RelPath

A path relative to something, which may start with .., written os.RelPath("a/b") or os.up / "a".

os.SubPath

A relative path that can never climb out of its base, written os.sub / "a" / "b".

A Path is built from a known one with the / operator. os.pwd is the working directory, os.home the user’s home directory and os.root the file system root; os.temp and os.temp.dir create files and directories in the temporary directory (deleted when the JVM exits, unless you pass deleteOnExit = false). Accessors give the parts back, and relativeTo goes the other way:

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def paths(): Unit =
  val base = os.temp.dir(prefix = "trailhead")
  val route = base / "routes" / "ridge.txt"
  println(route.last)
  println(route.ext)
  println(route.baseName)
  println(route.segments.toList.takeRight(2))
  println(route.relativeTo(base))
  println((route / os.up).relativeTo(base))
  println(os.RelPath("routes/ridge.txt") == route.relativeTo(base))
  println(os.Path("routes/ridge.txt", base) == route)
  println(os.pwd.getClass.getSimpleName)
  try os.Path("routes/ridge.txt")
  catch case e: IllegalArgumentException => println(e.getClass.getSimpleName)
  val sub = os.sub / "a" / "b"
  println(sub)
  println(base / os.up == base / os.RelPath(".."))
  os.remove.all(base)
ridge.txt
txt
ridge
List(routes, ridge.txt)
routes/ridge.txt
routes
true
true
Path
IllegalArgumentException
a/b
true

os.Path("routes/ridge.txt") with a relative string and no base throws IllegalArgumentException; give a base as the second argument to resolve it, as the program does. A / on a Path takes a PathChunk, a string or relative path, and the compiler refuses anything else:

-- [E007] Type Mismatch Error: err5.scala:5:30
5 |  println(os.pwd / "routes" / 3)
  |                              ^
  |                              Found:    (3 : Int)
  |                              Required: os.PathChunk

Plain strings do not stand in for paths. A string literal that is passed where a path is expected is checked at compile time, and a relative one is rejected before the program runs; a string held in a variable or built at run time fails the same way. Convert it with os.Path(text, base) first:

-- Error: err2.scala:5:18
5 |  println(os.read("notes.txt"))
  |                  ^^^^^^^^^^^
  |                  Invalid absolute path literal: "notes.txt"

This is the biggest difference from Java, where Path.of("notes.txt") quietly depends on the working directory; with OS-Lib a relative location is a RelPath until you say what it is relative to. Absolute path literals such as os.read("/etc/shells") do compile.

Reading, writing and appending files

The reading functions are os.read (the whole file as a String), os.read.lines (a IndexedSeq[String]), os.read.bytes, os.read.chunks, and the lazy os.read.lines.stream. Writing has three flavours: os.write creates the file and fails if it already exists, os.write.append adds to the end, and os.write.over replaces the content (truncate = false overwrites in place without shortening the file). Passing createFolders = true also creates the missing parent directories. Data can be a String, an Array[Byte], or a Seq of them, which are written one after another.

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def files(): Unit =
  val base = os.temp.dir(prefix = "trailhead")
  val route = base / "routes" / "ridge.txt"

  os.write(route, "Start: car park\nStop: summit\n", createFolders = true)
  println(os.read(route))
  println(os.read.lines(route))
  println(os.size(route))

  os.write.append(route, "Stop: ridge\n")
  println(os.read.lines(route).last)

  try os.write(route, "again")
  catch case e: java.nio.file.FileAlreadyExistsException => println(s"exists: ${e.getClass.getSimpleName}")
  os.write.over(route, "Start: lake\n")
  println(os.read.lines(route))
  os.write.over(route, "over\n", truncate = false)
  println(os.read(route).trim)

  try os.read(base / "missing.txt")
  catch case e: java.nio.file.NoSuchFileException => println(s"missing: ${e.getClass.getSimpleName}")

  os.write(base / "routes" / "valley.txt", Seq("one\n", "two\n"))
  os.write(base / "readme.md", "# Routes")
  println(os.list(base).map(_.relativeTo(base)).sorted)
  println(os.list(base / "routes").map(_.last).sorted)
  println(os.walk(base).map(_.relativeTo(base)).sorted)
  println(os.walk(base, skip = _.last == "valley.txt").map(_.last).sorted)
  println(os.walk(base, maxDepth = 1).map(_.last).sorted)
  println(os.isDir(base / "routes"))
  println(os.isFile(route))
  println(os.exists(base / "nothing"))
  println(os.stat(route).size)

  os.copy(route, base / "copy.txt")
  os.move(base / "copy.txt", base / "moved.txt")
  println(os.exists(base / "copy.txt"))
  println(os.read(base / "moved.txt").trim)
  os.copy(base / "routes", base / "backup")
  println(os.list(base / "backup").map(_.last).sorted)
  try os.remove(base / "routes")
  catch case e: java.nio.file.DirectoryNotEmptyException => println(s"not empty: ${e.getClass.getSimpleName}")
  os.remove.all(base / "routes")
  println(os.exists(base / "routes"))
  println(os.remove(base / "nothing"))

  val nums = os.read.lines.stream(base / "moved.txt").map(_.toUpperCase).toList
  println(nums)
  os.remove.all(base)
  println(os.exists(base))
Start: car park
Stop: summit

ArraySeq(Start: car park, Stop: summit)
29
Stop: ridge
exists: FileAlreadyExistsException
ArraySeq(Start: lake)
over
: lake
missing: NoSuchFileException
ArraySeq(readme.md, routes)
ArraySeq(ridge.txt, valley.txt)
ArraySeq(readme.md, routes, routes/ridge.txt, routes/valley.txt)
ArraySeq(readme.md, ridge.txt, routes)
ArraySeq(readme.md, routes)
true
true
false
12
false
over
: lake
ArraySeq(ridge.txt, valley.txt)
not empty: DirectoryNotEmptyException
false
false
List(OVER, : LAKE)
false

Reading the output line by line:

  • The blank line after the first two lines is there because the text ends with a newline and println adds another.

  • os.write on an existing file threw FileAlreadyExistsException, so a typo cannot destroy a file; os.write.over replaced it.

  • os.write.over(route, "over\n", truncate = false) wrote five bytes at the start of a twelve byte file, which is why : lake is left over.

  • os.list returns only the direct children, os.walk the whole tree. Both return paths in an unspecified order, so the program sorts them (os.list(base, sort = true) sorts for you). os.walk accepts skip (a predicate; the matching paths are left out of the result) and maxDepth, and os.list.stream and os.walk.stream are lazy versions that avoid building the whole sequence.

  • os.copy copies a file or a whole directory tree; os.move renames or moves; os.remove deletes one file or an empty directory and returns false if there was nothing to delete; os.remove.all deletes recursively and never complains about a missing path.

  • os.isFile, os.isDir, os.exists, os.size and os.stat (size, timestamps, type) inspect a path.

Failures are the usual java.nio.file exceptions (NoSuchFileException, FileAlreadyExistsException, DirectoryNotEmptyException), all subclasses of java.io.IOException, so they can be handled with try, with scala.util.Try, or with the techniques on Either & Try and Exceptions, boundary & Early Exit.

More helpers, run in the next program: os.makeDir creates one directory and, like os.write, fails if it exists or the parent is missing; os.makeDir.all creates the whole chain; os.read.chunks yields byte arrays and the number of bytes in each.

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def pit(): Unit =
  try println(os.pwd / "a/b")
  catch case e: IllegalArgumentException => println(e.getMessage)
  val base = os.temp.dir()
  os.makeDir.all(base / "x" / "y")
  println(os.list.stream(base).map(_.last).toList)
  println(os.walk.stream(base).map(_.relativeTo(base)).toList)
  os.write(base / "d.bin", Array[Byte](1, 2, 3))
  println(os.read.bytes(base / "d.bin").toList)
  println(os.read.chunks(base / "d.bin", 2).map(_._2).toList)
  val f = os.temp("hello", suffix = ".txt")
  println(f.ext + " " + os.read(f))
  println(os.home.toString.nonEmpty)
  try os.makeDir(base / "p" / "q")
  catch case e: java.nio.file.NoSuchFileException => println("NoSuchFileException")
  println(os.makeDir(base / "z"))
  try os.makeDir(base / "z")
  catch case e: java.nio.file.FileAlreadyExistsException => println("FileAlreadyExistsException")
  println(os.list(base).sorted.map(_.last))
  println(os.list(base, sort = true).map(_.last))
/private/tmp/.../os-lib/a/b
List(x)
List(x, x/y)
List(1, 2, 3)
List(2, 1)
txt hello
true
NoSuchFileException
()
FileAlreadyExistsException
ArraySeq(d.bin, x, z)
ArraySeq(d.bin, x, z)

The first line shows that a slash inside one string chunk is accepted and becomes two segments in the result. The output has been shortened where it printed the machine-specific directory under /private/tmp. os.makeDir returns (), os.temp("hello", suffix = ".txt") creates a temporary file with content, and os.read.chunks(path, 2) returned a chunk of 2 bytes and one of 1.

Running processes

os.proc(args*) describes a command and .call() runs it to completion. Each argument can be a String, an os.Path, an os.RelPath, a number or a Seq of these (it is flattened), so no quoting or escaping is needed: the words are handed to the operating system without a shell, which also means that *, $HOME and | are not expanded. The result is an os.CommandResult with exitCode, out and err; out and err offer .text(), .trim(), .lines() and .bytes. By default the command runs in the current directory, the child’s stderr is shown on the parent’s stderr, and a non-zero exit code throws an os.SubprocessException.

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def procA(): Unit =
  val dir = os.temp.dir(prefix = "trailhead")
  os.write(dir / "b.txt", "beta\n")
  os.write(dir / "a.txt", "alpha\n")

  val ls = os.proc("ls", "-1", dir).call()
  println(ls.exitCode)
  println(ls.out.text())
  println(ls.out.lines())

  val inDir = os.proc("ls", "-1").call(cwd = dir)
  println(inDir.out.lines())

  val env = os.proc("sh", "-c", "echo $TRAIL").call(env = Map("TRAIL" -> "ridge"))
  println(env.out.trim())

  val sorted = os.proc("sort").call(stdin = "pear\napple\nfig\n")
  println(sorted.out.lines())

  val reversed = os.proc("sort", "-r").call(stdin = os.proc("ls", "-1", dir).spawn().stdout)
  println(reversed.out.lines())
  os.remove.all(dir)
0
a.txt
b.txt

Vector(a.txt, b.txt)
Vector(a.txt, b.txt)
ridge
Vector(apple, fig, pear)
Vector(b.txt, a.txt)

The arguments of call that matter most:

Parameter Effect

cwd

The working directory of the child.

env

Extra environment variables as a Map[String, String]; propagateEnv = false starts from an empty environment instead of inheriting the parent’s.

stdin

A String, bytes, another process’s output, os.Pipe, os.Inherit or a path.

stdout, stderr

os.Pipe (capture), os.Inherit (share the parent’s), os.Path (write to a file) or a callback such as os.ProcessOutput.Readlines.

mergeErrIntoOut

Send stderr into the same stream as stdout.

check

true (the default) throws on a non-zero exit code; false returns the result whatever the code.

timeout

Milliseconds after which the process is destroyed; -1 (the default) waits forever.

Exit codes, standard error, merging and timeouts:

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def procB(): Unit =
  try os.proc("ls", "/no/such/trail").call(stderr = os.Pipe)
  catch case e: os.SubprocessException =>
    println(e.result.exitCode)
    println(e.result.err.text().trim)

  val soft = os.proc("ls", "/no/such/trail").call(check = false, stderr = os.Pipe)
  println(soft.exitCode)
  println(soft.out.text().isEmpty)

  val both = os.proc("sh", "-c", "echo out; echo err 1>&2")
  val split = both.call(stderr = os.Pipe)
  println((split.out.trim(), split.err.trim()))
  println(both.call(mergeErrIntoOut = true).out.lines().sorted)

  val slow = os.proc("sleep", 5).call(timeout = 300, check = false)
  println(slow.exitCode)
  try os.proc("sleep", 5).call(timeout = 300)
  catch case e: os.SubprocessException => println(s"checked: ${e.result.exitCode}")

  os.proc("echo", "inherited").call(stdout = os.Inherit)
1
ls: /no/such/trail: No such file or directory
1
true
(out,err)
Vector(err, out)
143
checked: 143
inherited

A failed command throws, but the exception carries the whole CommandResult in e.result. The stderr = os.Pipe argument keeps the child’s complaint out of the parent’s terminal so it can be read back. A command killed by the timeout reports 143 (128 plus SIGTERM) and, because the second call kept check = true, throws a SubprocessException too. stdout = os.Inherit lets the child write straight to the parent’s terminal, which is what you want for long builds whose output should stream live.

A command that does not exist cannot be started at all and throws java.io.IOException instead of a SubprocessException:

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def procD(): Unit =
  try os.proc("no-such-trail-tool").call()
  catch case e: java.io.IOException => println(e.getClass.getSimpleName)
  println("done")
IOException
done

With OS-Lib 0.11.8 on JDK 26 the same run also printed two long Exception in thread "subprocess-shutdown-hook" stack traces on stderr, from the library’s own cleanup threads; they did not change the result or the exit code of the program.

Interactive and long-running processes

.call() waits for the end. .spawn() returns a os.SubProcess straight away, with stdin, stdout and stderr streams to talk to it, and isAlive(), waitFor(), exitCode() and destroy() to control it (destroy(shutdownGracePeriod = 0) kills at once; destroyForcibly() is deprecated). stdout.readLine() returns null at the end of the stream, and stdout.lines() is an iterator over what is left.

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def procC(): Unit =
  val upper = os.proc("tr", "a-z", "A-Z").spawn()
  upper.stdin.write("hello\n")
  upper.stdin.flush()
  upper.stdin.close()
  println(upper.stdout.readLine())
  println(upper.waitFor())
  println(upper.exitCode())

  val shell = os.proc("sh", "-c", "echo one; echo two 1>&2; exit 2").spawn(stderr = os.Pipe)
  println(shell.stdout.readLine())
  println(shell.stderr.readLine())
  println(shell.stdout.readLine())
  shell.waitFor()
  println(shell.exitCode())

  val lost = os.proc("cat").spawn()
  lost.stdin.writeLine("only")
  lost.stdin.close()
  println(lost.stdout.lines().toList)

  val sleeper = os.proc("sleep", 10).spawn()
  println(sleeper.isAlive())
  sleeper.destroy()
  sleeper.waitFor()
  println(sleeper.isAlive())
  println(sleeper.exitCode())
HELLO
true
0
one
two
null
2
List()
true
false
143

Points worth noticing:

  • waitFor() returns a Boolean (true once the process has ended), not the exit code; read the code with exitCode() afterwards.

  • The stdin stream is buffered. In the lost process stdin.close() without flush() sent nothing, and cat printed an empty list, while upper called flush() before close() and got HELLO back. With OS-Lib 0.11.8, call flush() before close() whenever you write to a child’s stdin.

  • A destroyed process reports 143 because the JVM sent SIGTERM.

  • To feed one process into another, pass a.spawn().stdout as the stdin of b.call(), as the sort example did; the intermediate result is streamed and never held in memory.

Spawned processes are also the way to run something in the background while a program does other work, for instance a web server started from a test. Concurrency on top of that, with Future, is on Futures & Promises.

The standard library alternatives

Scala’s own library has had scala.io.Source for reading text files and scala.sys.process for commands for a long time, and the JDK has java.nio.file and ProcessBuilder. They need no dependency, which matters for a library; OS-Lib wins for scripts and tools.

Files with java.nio.file and scala.io.Source

scala.io.Source is read-only, must be closed (scala.util.Using.resource does that), and has no writing counterpart, so writing falls back to java.nio.file.Files:

//> using scala 3.10.0

import scala.io.Source
import scala.util.Using
import java.nio.file.{Files, Path}

@main def stdio(): Unit =
  val file: Path = Files.createTempFile("trailhead", ".txt")
  Files.writeString(file, "Start: car park\nStop: summit\n")

  val lines = Using.resource(Source.fromFile(file.toFile))(_.getLines().toList)
  println(lines)
  println(Files.readAllLines(file))
  Files.delete(file)
List(Start: car park, Stop: summit)
[Start: car park, Stop: summit]

Processes with scala.sys.process

import scala.sys.process.* adds methods to Seq[String] and String. !! runs the command and returns its output as a String (and throws RuntimeException on a non-zero exit code), ! returns the exit code, and lazyLines streams the lines. The operators #| (pipe), #> and #>> (redirect and append to a file), #< (input from a file, URL or stream), #&& and #|| (run the next command on success or on failure) build pipelines, and Process(command, cwd, env*) sets the directory and environment.

//> using scala 3.10.0

import scala.sys.process.*
import java.io.File

@main def sysproc(): Unit =
  val dir = java.nio.file.Files.createTempDirectory("trailhead").toFile
  java.nio.file.Files.writeString(File(dir, "b.txt").toPath, "beta\n")
  java.nio.file.Files.writeString(File(dir, "a.txt").toPath, "alpha\n")

  val listing: String = Seq("ls", "-1", dir.getPath).!!
  println(listing.linesIterator.toList)
  println(Seq("true").!)
  println(Seq("sh", "-c", "exit 4").!)

  try Seq("sh", "-c", "exit 4").!!
  catch case e: RuntimeException => println(e.getMessage)

  val sorted = (Seq("ls", "-1", dir.getPath) #| Seq("sort", "-r")).!!
  println(sorted.linesIterator.toList)

  val out = File(dir, "out.txt")
  (Seq("echo", "saved") #> out).!
  (Seq("echo", "more") #>> out).!
  println(java.nio.file.Files.readAllLines(out.toPath))

  val lines = Process(Seq("ls", "-1"), dir).lazyLines.toList
  println(lines)
  println(Process(Seq("sh", "-c", "echo $TRAIL"), None, "TRAIL" -> "ridge").!!.trim)

  val outBuf, errBuf = StringBuilder()
  val code = Seq("sh", "-c", "echo out; echo err 1>&2; exit 1")
    .!(ProcessLogger(l => outBuf.append(l), l => errBuf.append(l)))
  println((code, outBuf.toString, errBuf.toString))

  println((Seq("grep", "alpha") #< File(dir, "a.txt")).!!.trim)
  println((Seq("cat") #< java.io.ByteArrayInputStream("from memory".getBytes)).!!.trim)
  try Seq("no-such-trail-tool").!
  catch case e: java.io.IOException => println(e.getClass.getSimpleName)
  println((Seq("false") #|| Seq("echo", "fallback")).!!.trim)
List(a.txt, b.txt)
0
4
Nonzero exit value: 4
List(b.txt, a.txt)
[saved, more]
List(a.txt, b.txt, out.txt)
ridge
(1,out,err)
alpha
from memory
IOException
fallback

A string with spaces is split on whitespace when it is converted to a command ("ls -l".!!), so any argument that contains a space, such as a path, must go in a Seq, as above; this is the main trap and the reason OS-Lib takes arguments as separate values. The RuntimeException from !! carries only the exit code in its message, whereas SubprocessException brings the captured output. Redirection needs the #> forms because there is no shell; Seq("a") #| Seq("b") is a Scala-level pipe of two processes, not a shell pipeline.

Task OS-Lib Standard library

Read a file

os.read(path)

Files.readString(path), Source.fromFile

Write or append

os.write, os.write.append, os.write.over

Files.writeString(path, text), with StandardOpenOption.APPEND to append

List a directory

os.list(dir), os.walk(dir)

Files.list(dir), Files.walk(dir) (streams to close)

Copy, move, delete tree

os.copy, os.move, os.remove.all

Files.copy, Files.move, no recursive delete

Run a command

os.proc("ls", "-1").call()

Seq("ls", "-1").!!, ProcessBuilder

Exit status

result.exitCode, check = false

! returns it, !! throws

Environment and cwd

call(cwd = dir, env = Map("K" -> "V"))

Process(cmd, cwd, "K" -> "V")

Pitfalls

  • Arguments are not parsed by a shell. os.proc("echo", "$HOME", "*") prints $HOME *. Run os.proc("sh", "-c", command) when you really want shell syntax, and then escape the input yourself.

  • Do not build file names by string concatenation. Use base / name and os.Path(text, base); an os.up segment is resolved when the path is built, so an os.Path never contains ...

  • os.write and os.copy refuse to overwrite. Use os.write.over and os.copy(from, to, replaceExisting = true) when replacing is intended; both otherwise throw FileAlreadyExistsException.

  • Unordered directory listings. Sort the result of os.list and os.walk if the order matters, for example in tests.

  • Temporary files live until the JVM exits. os.temp and os.temp.dir delete on exit by default, which is too late for a long-running program; call os.remove.all when done, as the programs above do. With deleteOnExit = false the files stay for good.

  • flush() before close() on a spawned stdin, as shown above.

  • Reading a huge file with os.read loads it all in memory; use os.read.lines.stream or os.read.chunks.

  • Blocking. call blocks the calling thread until the process ends; run it on a thread pool or in a Future if that matters.

Where the books are outdated

The Toolkit tutorial pages that this page follows still show the first Toolkit versions in their examples (Toolkit 0.7.0 for Scala 3.4.2 and OS-Lib 0.11.3) and //> using toolkit latest; use an explicit version such as 0.9.2 so that a build is reproducible. Older Scala 2 books describe scala.io.Source and scala.sys.process as the way to read files and run commands: both still exist, but scripts today generally use OS-Lib, and a library that must stay dependency-free uses java.nio.file. No book text is reproduced here.

What was run and what was not

Every program on this page was run with Scala CLI 1.17.1, Scala 3.10.0, Toolkit 0.9.2 (OS-Lib 0.11.8) on JDK 26, on macOS. The Scala CLI runs also printed several WARNING: A terminally deprecated method in sun.misc.Unsafe lines from the Scala 3.10 library on JDK 26, which are omitted. The sbt snippets were not run. Windows behaviour (path separators, cmd) was not tested.