diff --git a/pydexec/command.py b/pydexec/command.py
index b2ba787..85478c7 100644
--- a/pydexec/command.py
+++ b/pydexec/command.py
@@ -12,6 +12,7 @@ class Command(object):
         self._args = []
         self._user = None
         self._env = dict(os.environ)
+        self._workdir = os.getcwd()
 
     def args(self, *args):
         """ Add a list of extra arguments to the command. """
@@ -90,22 +91,37 @@ class Command(object):
         self._user = User.from_spec(user)
         return self
 
+    def workdir(self, directory):
+        """
+        Change the current working directory to the given directory path before
+        executing the command. Note that, unlike the WORKDIR Dockerfile
+        directive, this will not cause the specified directory to be created.
+        """
+        self._workdir = directory
+        return self
+
     def run(self):
         cmd = [self._program] + self._args
 
-        kwargs = {'env': self._env}
+        kwargs = {
+            'env': self._env,
+            'preexec_fn': self._preexec_fn,
+        }
         if self._user is not None:
             env = self._env.copy()
             env['HOME'] = self._user.home
-            kwargs = {
-                'env': env,
-                'preexec_fn': self._user.set_user
-            }
+            kwargs['env'] = env
 
         retcode = Popen(cmd, **kwargs).wait()
         if retcode:
             raise CalledProcessError(retcode, cmd)
 
+    def _preexec_fn(self):
+        if self._user is not None:
+            self._user.set_user()
+
+        os.chdir(self._workdir)
+
     def exec_(self):
         """
         Exec the process, replacing the current process with the command.
@@ -116,4 +132,6 @@ class Command(object):
             self._user.set_user()
             self._env['HOME'] = self._user.home
 
+        os.chdir(self._workdir)
+
         os.execvpe(self._program, cmd, self._env)

Change working directory
diff --git a/pydexec/tests/test_command.py b/pydexec/tests/test_command.py
index 31b4f15..e72decc 100644
--- a/pydexec/tests/test_command.py
+++ b/pydexec/tests/test_command.py
@@ -1,12 +1,14 @@
 # -*- coding: utf-8 -*-
+import multiprocessing
 import os
-from multiprocessing import Process
+import sys
+import traceback
 from subprocess import CalledProcessError
 
 import pytest
 from testtools import ExpectedException
 from testtools.assertions import assert_that
-from testtools.matchers import Equals
+from testtools.matchers import Equals, Not
 
 from pydexec.command import Command
 from pydexec.tests.helpers import captured_lines
@@ -25,11 +27,42 @@ def run_cmd(cmd):
     return cmd.run()
 
 
+class ExceptionProcess(multiprocessing.Process):
+    """
+    Multiprocessing Process that can be queried for an exception that occurred
+    in the child process.
+    http://stackoverflow.com/a/33599967
+    """
+    def __init__(self, *args, **kwargs):
+        multiprocessing.Process.__init__(self, *args, **kwargs)
+        self._pconn, self._cconn = multiprocessing.Pipe()
+        self._exception = None
+
+    def run(self):
+        try:
+            multiprocessing.Process.run(self)
+            self._cconn.send(None)
+        except Exception as e:
+            tb = traceback.format_exc()
+            self._cconn.send((e, tb))
+
+    @property
+    def exception(self):
+        if self._pconn.poll():
+            self._exception = self._pconn.recv()
+        return self._exception
+
+
 def exec_cmd(cmd):
     # Run the command in a separate process so that it can be exec-ed
-    p = Process(target=cmd.exec_)
+    p = ExceptionProcess(target=cmd.exec_)
     p.start()
     p.join()
+    if p.exception:
+        error, tb = p.exception
+        print(tb)
+        raise error
+
     if p.exitcode:
         # Simulate a CalledProcessError to simplify tests
         raise CalledProcessError(p.exitcode, [cmd._program] + cmd._args)
@@ -333,3 +366,90 @@ class TestCommand(object):
                 'option "--home" for program "/bin/sh"'):
             Command('/bin/sh').opt_from_env('--home', 'DOESNOTEXIST',
                                             required=True)
+
+    def test_workdir_changes_directory(self, capfd, tmpdir, runner):
+        """
+        When a directory is specified as the 'workdir' for a command, the
+        command's subprocess should be executed with the current working
+        directory as the specified directory.
+        """
+        cwd = os.getcwd()
+
+        runner(Command('/bin/pwd').workdir(str(tmpdir)))
+
+        out_lines, _ = captured_lines(capfd)
+        child_cwd = out_lines.pop(0)
+        assert_that(child_cwd, Equals(str(tmpdir)))
+
+        # Assert only the working directory of the child process has changed
+        assert_that(child_cwd, Not(Equals(cwd)))
+        assert_that(cwd, Equals(os.getcwd()))
+
+    def test_workdir_inherited(self, capfd, runner):
+        """
+        When a command is run its child process should inherit the current
+        working directory.
+        """
+        cwd = os.getcwd()
+
+        runner(Command('/bin/pwd'))
+
+        out_lines, _ = captured_lines(capfd)
+        assert_that(out_lines.pop(0), Equals(cwd))
+
+    def test_workdir_set_at_command_creation(self, capfd, tmpdir, runner):
+        """
+        When a command is run its child process should inherit the current
+        working directory at the time the Command object is initialised and
+        changes to the parent process's current working directory should have
+        no effect on the command.
+        """
+        old_cwd = os.getcwd()
+        new_cwd = str(tmpdir)
+
+        # Command created before chdir
+        cmd = Command('/bin/pwd')
+
+        # Change parent process's current working directory
+        os.chdir(new_cwd)
+        assert_that(os.getcwd(), Equals(new_cwd))
+
+        runner(cmd)
+        out_lines, _ = captured_lines(capfd)
+        assert_that(out_lines.pop(0), Equals(old_cwd))
+
+    @pytest.mark.skipif(sys.version_info >= (3, 0),
+                        reason='only for Python < 3')
+    def test_workdir_does_not_exist(self, capfd, runner):
+        """
+        When the command is run and the specified workdir does not exist, an
+        error is raised.
+        """
+        with ExpectedException(
+            OSError,
+                r"\[Errno 2\] No such file or directory: 'DOESNOTEXIST'"):
+            runner(Command('/bin/pwd').workdir('DOESNOTEXIST'))
+
+    @pytest.mark.skipif(sys.version_info[0] < 3,
+                        reason='requires Python 3')
+    def test_workdir_does_not_exist_exec(self, capfd, runner):
+        """
+        When the command is run and the specified workdir does not exist, an
+        error is raised.
+        """
+        with ExpectedException(
+            FileNotFoundError,  # noqa: F821
+                r"\[Errno 2\] No such file or directory: 'DOESNOTEXIST'"):
+            exec_cmd(Command('/bin/pwd').workdir('DOESNOTEXIST'))
+
+    @pytest.mark.skipif(sys.version_info < (3, 3),
+                        reason='requires Python 3.3')
+    def test_workdir_does_not_exist_run(self, capfd):
+        """
+        When the command is run and the specified workdir does not exist, an
+        error is raised.
+        """
+        from subprocess import SubprocessError
+        with ExpectedException(
+                SubprocessError, r'Exception occurred in preexec_fn\.'):
+            run_cmd(Command('/bin/pwd').workdir('DOESNOTEXIST'))

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest
apt-get update apt-get install -y gcc
3.9
requirements-dev.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
coverage==7.8.0
exceptiongroup==1.2.2
flake8==7.2.0
iniconfig==2.1.0
mccabe==0.7.0
packaging==24.2
pluggy==1.5.0
pycodestyle==2.13.0
-e git+https://github.com/praekeltfoundation/pydexec.git@ee5bed66d25dea943735cd5d5c05947d7623fecf#egg=pydexec
pyflakes==3.3.1
pytest==8.3.5
pytest-cov==6.0.0
pytest-cover==3.0.0
pytest-coverage==0.0
testtools==2.7.2
tomli==2.2.1

name: pydexec
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - flake8==7.2.0
      - iniconfig==2.1.0
      - mccabe==0.7.0
      - packaging==24.2
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pyflakes==3.3.1
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - pytest-cover==3.0.0
      - pytest-coverage==0.0
      - testtools==2.7.2
      - tomli==2.2.1
prefix: /opt/conda/envs/pydexec

pydexec/tests/test_command.py::TestCommand::test_workdir_changes_directory[run_cmd] pydexec/tests/test_command.py::TestCommand::test_workdir_set_at_command_creation[run_cmd] pydexec/tests/test_command.py::TestCommand::test_workdir_does_not_exist_exec[run_cmd] pydexec/tests/test_command.py::TestCommand::test_workdir_changes_directory[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_workdir_set_at_command_creation[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_workdir_does_not_exist_exec[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_workdir_does_not_exist_run
pydexec/tests/test_command.py::TestCommand::test_stdout[run_cmd] pydexec/tests/test_command.py::TestCommand::test_stderr[run_cmd] pydexec/tests/test_command.py::TestCommand::test_output_unicode[run_cmd] pydexec/tests/test_command.py::TestCommand::test_error[run_cmd] pydexec/tests/test_command.py::TestCommand::test_preserves_environment[run_cmd] pydexec/tests/test_command.py::TestCommand::test_switch_user[run_cmd] pydexec/tests/test_command.py::TestCommand::test_switch_user_preserves_environment[run_cmd] pydexec/tests/test_command.py::TestCommand::test_env[run_cmd] pydexec/tests/test_command.py::TestCommand::test_env_remove[run_cmd] pydexec/tests/test_command.py::TestCommand::test_env_clear[run_cmd] pydexec/tests/test_command.py::TestCommand::test_arg_from_env[run_cmd] pydexec/tests/test_command.py::TestCommand::test_arg_from_env_not_present[run_cmd] pydexec/tests/test_command.py::TestCommand::test_arg_from_env_no_remove[run_cmd] pydexec/tests/test_command.py::TestCommand::test_arg_from_env_default[run_cmd] pydexec/tests/test_command.py::TestCommand::test_opt_from_env[run_cmd] pydexec/tests/test_command.py::TestCommand::test_opt_from_env_not_present[run_cmd] pydexec/tests/test_command.py::TestCommand::test_opt_from_env_no_remove[run_cmd] pydexec/tests/test_command.py::TestCommand::test_opt_from_env_default[run_cmd] pydexec/tests/test_command.py::TestCommand::test_workdir_inherited[run_cmd] pydexec/tests/test_command.py::TestCommand::test_stdout[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_stderr[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_output_unicode[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_error[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_preserves_environment[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_env[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_env_remove[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_env_clear[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_arg_from_env[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_arg_from_env_not_present[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_arg_from_env_no_remove[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_arg_from_env_default[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_opt_from_env[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_opt_from_env_not_present[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_opt_from_env_no_remove[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_opt_from_env_default[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_workdir_inherited[exec_cmd] pydexec/tests/test_command.py::TestCommand::test_arg_from_env_required pydexec/tests/test_command.py::TestCommand::test_opt_from_env_required
pydexec/command.py
pydexec/command.py