How HTTP Works

Request header

> If-Match: "33a64df5"

If-Match Header

Learn how the If-Match header makes requests conditional based on ETag matching. Prevent conflicts and lost updates in concurrent editing scenarios.

Reviewed 7 min readadvanced2 sourcesTry itMarkdown
Direction
Request
Category
Conditional requests
Spec
RFC 9110 §13.1.1
JS can set it
Yes
CORS-safelisted
No: may trigger a preflight
On this page

TL;DR: If-Match stops you from overwriting someone else’s change. Put the ETag from your last GET in If-Match on your PUT, PATCH or DELETE. If the resource has changed since, the server skips the write and returns 412 Precondition Failed; refetch, merge, and try again. On the server, check the tag and write in one atomic step, or two writers can race past it.

What is If-Match?

If-Match makes a request conditional: the server only acts if the resource exists and its current ETag matches one you sent. You’ll mostly use it on writes. It also works on GET when you want one specific version or an error. RFC 9110 §13.1.1 defines it.

Syntax

Three forms (examples on this page are trimmed):

If-Match: "revision-a"
If-Match: "revision-a", "revision-b"
If-Match: *

A list succeeds if any tag strongly matches. The wildcard succeeds whenever the resource currently exists, whatever its revision. Send * on its own, never inside a tag list.

How If-Match Works

Say your GET returned ETag: "revision-a", and another editor saves a change before your PUT goes out. Your saved tag no longer matches, so the server rejects your write.

The request, with a five-byte body:

PUT /notes/current HTTP/1.1
Host: example.com
If-Match: "revision-a"
Content-Type: text/plain
Content-Length: 5

hello

The rejection:

HTTP/1.1 412 Precondition Failed
Content-Length: 0

Refetch, reconcile your edit with the new version, then retry. If you just swap in the new tag and resend the old edit, you overwrite the other person’s change and the check bought you nothing.

Common Examples

If-Match: * stops a PUT from creating a resource that doesn’t exist yet. A specific tag on DELETE makes sure you delete the version you looked at. On a GET with Range, a failed If-Match gets you a 412 rather than the new full representation; use If-Range when you’d rather receive the full response.

An editor should hold on to the ETag of the version the user started editing. This browser example loads a text note, then saves the user’s edited text with that ETag:

async function openNote(url) {
  const response = await fetch(url, { cache: 'no-store' });
  if (!response.ok) throw new Error(`Read failed: ${response.status}`);
  const etag = response.headers.get('etag');
  if (!etag || etag.startsWith('W/')) {
    throw new Error('This editor requires a strong ETag');
  }
  return { url, etag, original: await response.text() };
}

async function saveNote(note, editedText) {
  const response = await fetch(note.url, {
    method: 'PUT',
    headers: { 'If-Match': note.etag, 'Content-Type': 'text/plain' },
    body: editedText,
  });
  if (response.status === 412) {
    const latest = await openNote(note.url);
    return { conflict: true, original: note.original, editedText, latest };
  }
  if (!response.ok) throw new Error(`Save failed: ${response.status}`);
  return { conflict: false, saved: await openNote(note.url) };
}

On a conflict, show all three versions: the original, the user’s edit, and the latest one. That way nobody loses work and the user can merge. Resist fetching a fresh ETag right before each save. It only protects the split second after that read and misses every change made while the user was typing. For a cross-origin API, expose ETag through CORS so the first function can read it.

Server Implementation

Run your normal request checks first, then evaluate the precondition, then apply the method. RFC 9110 §13.2.1 says to ignore preconditions when the same request without them would have produced something other than 2xx or 412, so an authorization error or a redirect wins over a 412.

Do the version comparison and the write in one transaction or one conditional database operation. If your code checks the tag, then awaits a separate write, another writer can slip in between and you lose an update anyway.

Parse the header properly. A substring test like header.includes(currentETag) matches the current tag inside W/"revision-a", and weak tags must never match here. Splitting on commas isn’t enough either, because the quoted tag itself can contain a comma. A strong match needs two non-weak tags with identical opaque values.

Here’s a Flask demo for a single text resource that always exists. Save it as match_demo.py somewhere Flask is installed and run flask --app match_demo run. The PUT path uses Werkzeug’s parsed ETags to do a strong comparison instead of searching the raw header:

from hashlib import sha256
from threading import Lock
from flask import Flask, Response, request

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024
lock = Lock()
body = b'hello'

def reply(data):
    response = Response(data, content_type='text/plain')
    response.set_etag(sha256(data).hexdigest())
    response.headers['Cache-Control'] = 'no-store'
    return response

@app.route('/note', methods=['GET', 'PUT'])
def note():
    global body
    if request.method in ('GET', 'HEAD'):
        with lock:
            return reply(body)
    if request.content_type != 'text/plain':
        return Response(status=415)
    if 'If-Match' not in request.headers:
        return Response('Send If-Match for this endpoint', status=428)
    replacement = request.get_data()
    with lock:
        current = sha256(body).hexdigest()
        if not request.if_match.contains(current):
            return Response(status=412)
        body = replacement
        return reply(body)

The 1 MiB body limit is just an example setting. The Flask API provides request.if_match and the response’s set_etag, which adds the quotes around the hash for you. If-Match: * matches here because the resource always exists, and a weak tag with the same hash doesn’t.

The demo keeps the check and write under one in-process lock and returns exactly the bytes the PUT stored. Its state lives in memory, so it resets on restart and isn’t shared between server processes. In a real application, put both steps inside the database transaction that owns the resource, and make every code path that modifies it follow the same rule. Returning 428 Precondition Required when If-Match is missing is a choice this endpoint makes; supporting If-Match doesn’t require it.

Getting If-Match right

When the condition fails, the server must not apply the method. The usual response is 412. The RFC also lets the server return a 2xx if it can tell the requested change has already been made, for example a retried PUT that already succeeded. A 412 doesn’t always mean someone else edited the resource; it may have been deleted, or the server may have selected a different representation.

If a request carries both If-Match and If-Unmodified-Since, the server ignores If-Unmodified-Since. You don’t get two checks stacked on top of each other.

A strong ETag belongs to one specific representation. If your API returns different bytes for JSON and another media type, or for compressed and uncompressed responses, each needs its own strong validator. Capture the ETag from the exact representation the user is editing, and have the server compare against that same representation. An application revision number works as an ETag only if it meets the strong-validator rules for what you actually serve.

Successful PUTs come with a validator rule too. RFC 9110 §9.3.4 forbids sending a validator in a successful PUT response unless the server saved the submitted representation without transforming it, and the validator reflects that saved version. If your API parses submitted JSON and stores or returns a normalized form, leave the ETag out of the PUT response and have the client refetch to get it.

A rejected write must leave both the content and the revision untouched. Check both in your concurrency tests; a 412 sent after the update already committed protects nothing.

If-Match vs If-None-Match

If-Match needs a match and uses strong comparison. If-None-Match needs no match and uses weak comparison. On a PUT, If-None-Match: * means “create only”, the mirror image of If-Match: *, which means “update only”.

Testing If-Match

Pick a throwaway resource on an API that supports conditional writes. Copy its real ETag, quotes included, into the PUT:

curl -sS -D - https://example.com/notes/current
curl -i -X PUT https://example.com/notes/current \
  -H 'If-Match: "revision-a"' \
  -H 'Content-Type: text/plain' --data-binary 'hello'

Once that succeeds, send another PUT with the old tag and a different body. You should get 412, and the resource should be unchanged. Also try the weak form of the current tag (W/"..."); it should fail the strong comparison.

Against the local Flask route, read the current tag, then send two different replacements using it:

curl -sS -D /tmp/note.headers http://127.0.0.1:5000/note

Copy the quoted ETag from that response into the following commands:

curl -i -X PUT http://127.0.0.1:5000/note \
  -H 'If-Match: "copied-tag"' -H 'Content-Type: text/plain' \
  --data-binary 'first edit'
curl -i -X PUT http://127.0.0.1:5000/note \
  -H 'If-Match: "copied-tag"' -H 'Content-Type: text/plain' \
  --data-binary 'second edit'

The first PUT succeeds and the note gets a new tag. The second should fail with 412, leaving first edit in place. Then leave out If-Match to see this route’s 428, and try If-Match: * to see how much weaker an existence check is than a version check.

Frequently asked questions

What is If-Match?

If-Match is a request header that says “only do this if the resource still has this ETag”. With * it means “only if the resource exists”. It is mostly used on PUT, PATCH and DELETE to stop lost updates, but works on any method.

How does If-Match prevent lost updates?

Send the strong ETag from your last read. If someone else changed the resource since, the tag no longer matches and the server returns 412 instead of writing. The server must do the comparison and the write atomically for this to hold.

What does If-Match: * mean?

If-Match: * succeeds when the origin has a current representation for the target resource. It checks existence, not a specific version.

When should I use If-Match vs If-Unmodified-Since?

If-Match compares strong ETags. If-Unmodified-Since uses dates with second precision and is ignored when If-Match is present.

Sources

  1. MDN Web Docs: If-Matchdeveloper.mozilla.org
  2. RFC 9110: If-Matchrfc-editor.org

Keep going

Browse /search