diff --git a/lazy_build/cache.py b/lazy_build/cache.py
index 27124d9..f1ec74a 100644
--- a/lazy_build/cache.py
+++ b/lazy_build/cache.py
@@ -20,10 +20,15 @@ class S3Backend(collections.namedtuple('S3Backend', (
     def key_for_ctx(self, ctx):
         return self.path.rstrip('/') + '/' + ctx.hash
 
+    def artifact_paths(self, ctx):
+        key = self.key_for_ctx(ctx)
+        return key + '.tar.gz', key + '.json'
+
     def has_artifact(self, ctx):
+        tarball, json = self.artifact_paths(ctx)
         # what a ridiculous dance we have to do here...
         try:
-            self.s3.Object(self.bucket, self.key_for_ctx(ctx)).load()
+            self.s3.Object(self.bucket, tarball).load()
         except botocore.exceptions.ClientError as ex:
             if ex.response['Error']['Code'] == '404':
                 return False
@@ -33,18 +38,20 @@ class S3Backend(collections.namedtuple('S3Backend', (
             return True
 
     def get_artifact(self, ctx):
+        tarball, json = self.artifact_paths(ctx)
         fd, path = tempfile.mkstemp()
         os.close(fd)
         self.s3.Bucket(self.bucket).download_file(
-            self.key_for_ctx(ctx),
+            tarball,
             path,
         )
         return path
 
     def store_artifact(self, ctx, path):
+        tarball, json = self.artifact_paths(ctx)
         self.s3.Bucket(self.bucket).upload_file(
             path,
-            self.key_for_ctx(ctx),
+            tarball,
         )
 
     def invalidate_artifact(self, ctx):
diff --git a/lazy_build/context.py b/lazy_build/context.py
index eadebb9..d7e0009 100644
--- a/lazy_build/context.py
+++ b/lazy_build/context.py
@@ -96,7 +96,7 @@ def build_context(conf, command):
 def package_artifact(conf):
     fd, tmp = tempfile.mkstemp()
     os.close(fd)
-    with tarfile.TarFile(tmp, mode='w') as tf:
+    with tarfile.open(tmp, mode='w:gz') as tf:
         for output_path in conf.output:
             if os.path.isdir(output_path):
                 for path, _, filenames in os.walk(output_path):
@@ -115,5 +115,5 @@ def extract_artifact(conf, artifact):
             else:
                 os.remove(output_path)
 
-    with tarfile.TarFile(artifact) as tf:
+    with tarfile.open(artifact, 'r:gz') as tf:
         tf.extractall()

gzip before upload
In some common cases (e.g. node_modules), this reduces ~300MB artifacts to ~100MB artifacts. Probably worth the CPU time.
diff --git a/tests/context_test.py b/tests/context_test.py
index 1e58218..b3271f5 100644
--- a/tests/context_test.py
+++ b/tests/context_test.py
@@ -77,7 +77,7 @@ def test_package_artifact(tmpdir):
         after_download=None,
     ))
     try:
-        with tarfile.TarFile(tmp) as tf:
+        with tarfile.open(tmp, 'r:gz') as tf:
             members = {member.name for member in tf.getmembers()}
     finally:
         os.remove(tmp)
@@ -94,7 +94,7 @@ def test_extract_artifact(tmpdir):
     tmpdir.join('a/b/sup').ensure()
 
     tar = tmpdir.join('my.tar').strpath
-    with tarfile.TarFile(tar, 'w') as tf:
+    with tarfile.open(tar, 'w:gz') as tf:
         for path in ('my.txt', 'hello/there.txt', 'a/b/c/d.txt'):
             ti = tarfile.TarInfo(path)
             ti.size = 6

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest covdefaults coverage twine
apt-get update apt-get install -y gcc
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
backports.tarfile==1.2.0
boto3==1.37.23
botocore==1.37.23
certifi==2025.1.31
cffi==1.17.1
charset-normalizer==3.4.1
covdefaults==2.3.0
coverage==7.8.0
cryptography==44.0.2
docutils==0.21.2
exceptiongroup==1.2.2
id==1.5.0
idna==3.10
importlib_metadata==8.6.1
iniconfig==2.1.0
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.1.0
jeepney==0.9.0
jmespath==1.0.1
keyring==25.6.0
-e git+https://github.com/chriskuehl/lazy-build.git@c53270b41e1d3716c301e65a283d99f86aa55bb9#egg=lazy_build
markdown-it-py==3.0.0
mdurl==0.1.2
more-itertools==10.6.0
nh3==0.2.21
packaging==24.2
pluggy==1.5.0
pycparser==2.22
Pygments==2.19.1
pytest==8.3.5
python-dateutil==2.9.0.post0
readme_renderer==44.0
requests==2.32.3
requests-toolbelt==1.0.0
rfc3986==2.0.0
rich==14.0.0
s3transfer==0.11.4
SecretStorage==3.3.3
six==1.17.0
tomli==2.2.1
twine==6.1.0
typing_extensions==4.13.0
urllib3==1.26.20
zipp==3.21.0

name: lazy-build
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:
      - backports-tarfile==1.2.0
      - boto3==1.37.23
      - botocore==1.37.23
      - certifi==2025.1.31
      - cffi==1.17.1
      - charset-normalizer==3.4.1
      - covdefaults==2.3.0
      - coverage==7.8.0
      - cryptography==44.0.2
      - docutils==0.21.2
      - exceptiongroup==1.2.2
      - id==1.5.0
      - idna==3.10
      - importlib-metadata==8.6.1
      - iniconfig==2.1.0
      - jaraco-classes==3.4.0
      - jaraco-context==6.0.1
      - jaraco-functools==4.1.0
      - jeepney==0.9.0
      - jmespath==1.0.1
      - keyring==25.6.0
      - markdown-it-py==3.0.0
      - mdurl==0.1.2
      - more-itertools==10.6.0
      - nh3==0.2.21
      - packaging==24.2
      - pluggy==1.5.0
      - pycparser==2.22
      - pygments==2.19.1
      - pytest==8.3.5
      - python-dateutil==2.9.0.post0
      - readme-renderer==44.0
      - requests==2.32.3
      - requests-toolbelt==1.0.0
      - rfc3986==2.0.0
      - rich==14.0.0
      - s3transfer==0.11.4
      - secretstorage==3.3.3
      - six==1.17.0
      - tomli==2.2.1
      - twine==6.1.0
      - typing-extensions==4.13.0
      - urllib3==1.26.20
      - zipp==3.21.0
prefix: /opt/conda/envs/lazy-build

tests/context_test.py::test_package_artifact tests/context_test.py::test_extract_artifact
tests/context_test.py::test_should_ignore_true[patterns0-venv] tests/context_test.py::test_should_ignore_true[patterns1-venv] tests/context_test.py::test_should_ignore_true[patterns2-this/is/some/venv] tests/context_test.py::test_should_ignore_true[patterns3-this/is/some/venv/with/a/file] tests/context_test.py::test_should_ignore_true[patterns4-this/is/some/venv/with/a/file] tests/context_test.py::test_should_ignore_true[patterns5-something.swp] tests/context_test.py::test_should_ignore_true[patterns6-hello/there/something.swp] tests/context_test.py::test_should_ignore_true[patterns7-my/.thing.txt.swo] tests/context_test.py::test_should_ignore_false[patterns0-this/is/some/venv] tests/context_test.py::test_should_ignore_false[patterns1-this/is/some/venv] tests/context_test.py::test_should_ignore_false[patterns2-venv2] tests/context_test.py::test_build_context_simple
lazy_build/context.py lazy_build/cache.py
lazy_build/context.py lazy_build/cache.py