Docker COPY vs ADD: When ADD Is the Right Choice
8 min readBytePatterns
Docker COPY vs ADD: both copy from the build context, ADD also unpacks local tar archives and fetches URLs. Why the docs prefer COPY, the one case for ADD.
Docker COPY vs ADD in one line: both copy files from the build context into the image, and ADD does two more things: it unpacks a local tar archive into the destination, and it can fetch a remote URL. Docker's own guidance is to use COPY unless you need one of those two features, because COPY does exactly one predictable thing. The case where ADD is right is a local tarball you want extracted, since a COPY followed by RUN tar would leave the archive in one layer and the extracted files in the next. Facts come from the Dockerfile reference and Docker's build guidance listed at the end, as of October 2026.
| Source | COPY | ADD |
|---|---|---|
| a file or directory in the context | copied as is | copied as is |
a local .tar, .tar.gz, .tar.bz2, .tar.xz | copied as the archive | unpacked into the destination |
an https:// URL | error | downloaded, never unpacked |
a file from another stage, --from=build | yes | yes |
The problem it solves
Two instructions do the same job nine times out of ten, so every Dockerfile review raises the question of which one to use, and every "why is there a fonts.tgz in my image" or "why did this ADD silently explode a directory tree" is the other side of it. The distinction matters in three places: what lands in the image, what the build downloads from outside the context, and what invalidates the layer cache.
The intuition
COPY is a copy. The reference describes it as copying new files or directories from a source, the build context or another stage, into the image's filesystem at a destination. Directories are copied with their contents, globbing works, and the options are about ownership and placement: --chown and --chmod set the owner and mode, --from names a stage or an image to copy from, --link makes the layer independent of the previous ones so it can be reused when they change, --parents keeps the source directory structure, and --exclude leaves patterns out. A .dockerignore file at the root of the context decides what is sent to the builder in the first place; anything excluded there cannot be copied by either instruction.
ADD is a copy with two special cases. The reference lists the same copying behaviour, and then:
- If the source is a local tar archive in a recognised compression format, identity, gzip, bzip2 or xz, it is unpacked as a directory into the destination. Whether a file counts as an archive is decided by its contents, not by its name.
- If the source is a remote URL, the file is downloaded to the destination. Remote archives are not unpacked. A
--checksumoption verifies the download, and the reference notes that a downloaded file gets permissions of 600. - A Git repository URL can also be cloned, with
--keep-git-dirto keep its history.
Why the docs prefer COPY. The build guidance says COPY is preferred because it is more transparent: it only supports the basic copying of local files. ADD has features that are not obvious from the line, so a reviewer cannot tell from ADD app.tgz /app whether a file or a tree arrives. The one case the guidance names for ADD is local tar auto-extraction. For a URL, the guidance's point is cache behaviour: an ADD of a URL with --checksum is cached by the checksum, so the layer rebuilds only when the pinned content changes, whereas a RUN curl has no idea whether the remote changed.
The cache implication. For both instructions, the reference says the cache check compares a checksum of the files' contents, not their last-modified or last-accessed times. Touching a file does not invalidate the layer; editing one byte does. That is the mechanism behind the lesson's ordering rule: copy the lockfile first, install, then copy the source. The same rule interacts with ADD of a URL: without --checksum, the builder cannot know whether the remote file changed, so it fetches again to find out.
The right choice, in order:
- A file or directory from the context or another stage:
COPY. - A local tarball you want unpacked:
ADD, and only then. - A remote file:
ADDwith--checksumwhen you want cache correctness and no extra tools in the image;RUN curlwhen you need to unpack or process it in the same step.
What each of these does to the layer cache across a build, including --cache-from for CI, is in Docker build cache: layers vs BuildKit cache mounts vs --cache-from.
Watch it run
The lesson animation builds a five-layer Dockerfile and keeps every layer in the build cache. Then it edits one source file: the COPY . . layer and the step after it rebuild, while the three above come from cache. Swapping the order to copy the source before npm ci makes the same edit reinstall every dependency. Both COPY lines in that file are content-addressed: the cache key is the bytes of the files they copy, which is why an edit, not a save, is what moves the rebuild line.
Images, Layers & Multi-Stage Builds
Step 1 of 11
Each Dockerfile instruction adds a layer: an immutable set of file changes, stacked in order.
The same interactive animation as the lesson — step through it with the controls.
The code
The lesson's build stage, with the three forms side by side:
# illustrative: the lesson's build stage plus the two ADD cases
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./ # plain files: COPY
RUN npm ci
COPY . . # the source tree: COPY
ADD vendor/fonts.tgz /app/fonts # local tarball, unpacked: ADD
ADD --checksum=sha256:24454f830cdb571e2c4ad15481119c43b3cafd48dd869a9b2945d1036d1dc68d \
https://example.com/tools.tar.gz /opt # remote file, downloaded, not unpacked
RUN npm run build
A toy model of the two instructions, not BuildKit. The build context is a dictionary of paths to bytes; the tarball is a real gzip tar built with the standard library, so the unpacking is real too:
import hashlib, io, tarfile
# The build context: a source directory, a vendored tarball, and a URL
def make_tar_gz(files):
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for name, data in files.items():
info = tarfile.TarInfo(name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return buf.getvalue()
CONTEXT = {
"dist/server.js": b"require('http').createServer().listen(8000)\n",
"dist/health.js": b"module.exports = () => 'ok'\n",
"vendor/fonts.tgz": make_tar_gz({"fonts/inter.woff2": b"\x00" * 64, "fonts/LICENSE": b"OFL\n"}),
}
REMOTE = {"https://example.com/tools.tar.gz": make_tar_gz({"tools/bin/jq": b"ELF"})}
def is_tar(data):
try:
tarfile.open(fileobj=io.BytesIO(data)).close()
return True
except tarfile.TarError:
return False
def copy(src, dest, image):
"""Toy model of COPY: a path in the build context, or a directory of them, byte for byte."""
if src.startswith(("http://", "https://")):
return f"COPY {src}: error, source can't be a URL"
matched = {p: d for p, d in CONTEXT.items() if p == src or p.startswith(src.rstrip("/") + "/")}
for path, data in matched.items():
image[dest.rstrip("/") + "/" + path.split("/")[-1]] = data
return f"COPY {src} -> {len(matched)} file(s)"
def add(src, dest, image):
"""Toy model of ADD: like COPY, plus remote URLs (downloaded, never
unpacked) and local tar archives (unpacked into dest)."""
if src.startswith(("http://", "https://")):
data = REMOTE[src]
image[dest.rstrip("/") + "/" + src.split("/")[-1]] = data
return f"ADD {src} -> downloaded 1 file, not unpacked"
data = CONTEXT.get(src)
if data is not None and is_tar(data):
with tarfile.open(fileobj=io.BytesIO(data)) as tar:
members = tar.getmembers()
for member in members:
image[dest.rstrip("/") + "/" + member.name] = tar.extractfile(member).read()
return f"ADD {src} -> unpacked {len(members)} file(s)"
return copy(src, dest, image).replace("COPY", "ADD")
image = {}
for line in [
("COPY", "dist/", "/app/dist"),
("COPY", "vendor/fonts.tgz", "/app/vendor"),
("ADD", "vendor/fonts.tgz", "/app/fonts"),
("ADD", "https://example.com/tools.tar.gz", "/opt"),
("COPY", "https://example.com/tools.tar.gz", "/opt"),
]:
op, src, dest = line
print((copy if op == "COPY" else add)(src, dest, image))
print()
for path in sorted(image):
print(f" {path:28} {len(image[path]):4} bytes")
# COPY dist/ -> 2 file(s)
# COPY vendor/fonts.tgz -> 1 file(s)
# ADD vendor/fonts.tgz -> unpacked 2 file(s)
# ADD https://example.com/tools.tar.gz -> downloaded 1 file, not unpacked
# COPY https://example.com/tools.tar.gz: error, source can't be a URL
#
# /app/dist/health.js 28 bytes
# /app/dist/server.js 44 bytes
# /app/fonts/fonts/LICENSE 4 bytes
# /app/fonts/fonts/inter.woff2 64 bytes
# /app/vendor/fonts.tgz 147 bytes
# /opt/tools.tar.gz 107 bytes
The same fonts.tgz arrived twice: COPY left a 147-byte archive under /app/vendor, ADD left two unpacked files under /app/fonts. The remote archive arrived as an archive, because ADD never unpacks what it downloads, and COPY refused the URL.
The cache key, in the same toy. Two builds where server.js has the same bytes but a newer timestamp share the layer; one edited byte does not:
def cache_key(instruction, src, data):
"""Toy model of the layer cache key for COPY/ADD: the instruction and the
file contents, not the file's timestamps."""
return hashlib.sha256(f"{instruction} {src}".encode() + data).hexdigest()[:12]
before = cache_key("COPY", "dist/server.js", CONTEXT["dist/server.js"])
touched = cache_key("COPY", "dist/server.js", CONTEXT["dist/server.js"]) # same bytes, new mtime
edited = cache_key("COPY", "dist/server.js", CONTEXT["dist/server.js"].replace(b"8000", b"9000"))
print(before, touched, "cache hit" if before == touched else "rebuild")
print(before, edited, "cache hit" if before == edited else "rebuild")
# f7b4a24879e2 f7b4a24879e2 cache hit
# f7b4a24879e2 f6fa3f7d44c7 rebuild
The complexity
The costs are image size and build time:
- Image size.
COPY archivethenRUN tar xfstores the archive in one layer and the files in the next; the archive is never removed from the image, because layers are additive.ADD archivestores only the unpacked files. For a large vendored tarball, this is the whole argument forADD. - Build time. Both instructions hash the source files to check the cache, which is linear in the bytes copied.
ADDof a URL without--checksumadds a network round trip on every build to learn whether the remote changed. - Transparency. A reviewer reading
COPYknows the result. ReadingADD, they must know whether the source is an archive, which the file name does not decide.
Where it goes wrong
ADDfor a file that happens to be a tarball. A.tgzmeant to stay packed is quietly exploded into the destination. UseCOPYfor an archive you want to keep as an archive.- Expecting
ADDto unpack a URL. Remote archives are downloaded as files. Unpacking needs aRUN. ADDof a URL with no checksum. The build cannot tell whether the remote changed, and an unpinned download is a supply-chain risk. Pin it with--checksum, or fetch it in aRUNyou control.COPYof something that.dockerignoreexcluded. The builder never saw it; the error is "not found" rather than "ignored".- Copying the whole context before the dependency install. Not a
COPYversusADDquestion, but the most expensive line in most Dockerfiles; the lesson's ordering rule fixes it. - A downloaded file's 600 permissions. A script fetched by
ADDis not executable until aRUN chmod.
When it shows up in interviews
Mostly as a quick knowledge check: "difference between COPY and ADD, and which do you use?" The expected answer names the two extra features of ADD, prefers COPY, and gives the one justified use. A follow-up that separates candidates is the cache: what invalidates a COPY layer, and why timestamps do not. That rule and the ordering it implies are the images, layers and multi-stage builds lesson; how ENTRYPOINT and CMD decide what the copied files do at start is in Docker CMD vs ENTRYPOINT.
How to say it in an interview
"COPY copies files or directories from the build context, or from another stage with --from, into the image. ADD does the same and adds two things: it unpacks a local tar archive into the destination, and it can download a URL, which it does not unpack. The docs prefer COPY because it is transparent; I use ADD only for a local tarball I want extracted, because COPY plus RUN tar would leave the archive in a layer forever. For remote files I pin with --checksum or fetch in a RUN. Both instructions cache on the content hash of the files, not their timestamps, so touching a file is a cache hit and editing it is a rebuild."
Frequently asked questions
What is the difference between COPY and ADD in a Dockerfile?
Both copy files from the build context into the image. ADD additionally unpacks a local tar archive, in gzip, bzip2, xz or uncompressed form, into the destination, and can download a remote URL, which it does not unpack. COPY has no special cases, which is why Docker's guidance prefers it.
When should I use ADD instead of COPY?
When you have a local tar archive and want its contents, not the archive, in the image: ADD app.tgz /app unpacks it in one layer, while COPY plus RUN tar keeps the archive in an earlier layer for good. For a remote file, ADD --checksum gives a pinned, cacheable download. Otherwise use COPY.
Does ADD extract a tar file from a URL?
No. The Dockerfile reference says resources from remote URLs are not decompressed; the file is downloaded to the destination as it is. Auto-extraction applies only to local archives from the build context. To unpack a downloaded archive, add a RUN tar step, or fetch and unpack it in a single RUN.
Sources
- Dockerfile reference: COPY — Docker Docs
- Dockerfile reference: ADD — Docker Docs
- Building best practices: ADD or COPY — Docker Docs
- Docker build cache — Docker Docs
- Build context — Docker Docs