Skip to content
BytePatterns

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.

SourceCOPYADD
a file or directory in the contextcopied as iscopied as is
a local .tar, .tar.gz, .tar.bz2, .tar.xzcopied as the archiveunpacked into the destination
an https:// URLerrordownloaded, never unpacked
a file from another stage, --from=buildyesyes

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 --checksum option 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-dir to 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:

  1. A file or directory from the context or another stage: COPY.
  2. A local tarball you want unpacked: ADD, and only then.
  3. A remote file: ADD with --checksum when you want cache correctness and no extra tools in the image; RUN curl when 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 archive then RUN tar xf stores the archive in one layer and the files in the next; the archive is never removed from the image, because layers are additive. ADD archive stores only the unpacked files. For a large vendored tarball, this is the whole argument for ADD.
  • Build time. Both instructions hash the source files to check the cache, which is linear in the bytes copied. ADD of a URL without --checksum adds a network round trip on every build to learn whether the remote changed.
  • Transparency. A reviewer reading COPY knows the result. Reading ADD, they must know whether the source is an archive, which the file name does not decide.

Where it goes wrong

  • ADD for a file that happens to be a tarball. A .tgz meant to stay packed is quietly exploded into the destination. Use COPY for an archive you want to keep as an archive.
  • Expecting ADD to unpack a URL. Remote archives are downloaded as files. Unpacking needs a RUN.
  • ADD of 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 a RUN you control.
  • COPY of something that .dockerignore excluded. The builder never saw it; the error is "not found" rather than "ignored".
  • Copying the whole context before the dependency install. Not a COPY versus ADD question, 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 ADD is not executable until a RUN 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