# If-Unmodified-Since Header

> Learn how the If-Unmodified-Since header makes requests conditional on resources not being modified. Prevent conflicts in concurrent update scenarios.

Source: https://howhttpworks.com/headers/if-unmodified-since
Last reviewed: 2026-10-05

> **TL;DR:** `If-Unmodified-Since` tells the server "only do this if the resource hasn't changed since this date." Send back the `Last-Modified` value from your earlier GET. If the resource is newer, the server returns `412 Precondition Failed` and makes no change. Prefer `If-Match` with an ETag when you can: it wins if both are sent, and dates can't tell apart two edits in the same second.

## What is If-Unmodified-Since?

It's a precondition based on a date. You read a resource, note its `Last-Modified`, and later send that date back with a write. If someone else changed the resource in between, the write is refused instead of silently overwriting their work. It's the tool to reach for when the server gives you `Last-Modified` but no ETag. See [RFC 9110 §13.1.4](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.4).

It works on reads too, refusing to return a resource that changed after a saved date:

```bash
curl -i -H 'If-Unmodified-Since: Mon, 05 Oct 2026 09:00:00 GMT' \
  https://example.com/notes/current
```

If the condition fails you get a 412, not the new content, so the client can't consume a changed version without noticing.

## Syntax

The value is an HTTP-date (examples on this page are trimmed):

```http
If-Unmodified-Since: Mon, 05 Oct 2026 09:00:00 GMT
```

Copy the server's `Last-Modified` value exactly. Using the client's clock, a Unix timestamp, or an ISO 8601 date won't work, because the server compares against its own clock and only accepts the HTTP-date format.

## How If-Unmodified-Since Works

With the date above, a resource last modified at exactly that second passes, and one modified any later fails. When the check fails, the server must not carry out the request. It normally returns 412. The RFC does allow one exception: if the server can tell that this same state change already happened (say, a retried request), it can respond with success.

A rejection looks like this:

```http
HTTP/1.1 412 Precondition Failed
Content-Length: 0

```

## Common Examples

Send the saved date on PUT or DELETE, assuming the endpoint supports the header. On a GET with `Range`, a failed check stops the request with a 412 instead of sending the whole new resource. If you'd rather get the full new version in that case, use [If-Range](https://howhttpworks.com/headers/if-range).

On the client, keep the `Last-Modified` date alongside the content the user is editing:

```javascript
async function readNote(url) {
  const response = await fetch(url, { cache: 'no-store' });
  if (!response.ok) throw new Error(`Read failed: ${response.status}`);
  const modified = response.headers.get('last-modified');
  if (!modified) throw new Error('No Last-Modified supplied');
  return { url, modified, text: await response.text() };
}

async function replaceNote(saved, text) {
  const response = await fetch(saved.url, {
    method: 'PUT',
    headers: {
      'Content-Type': 'text/plain',
      'If-Unmodified-Since': saved.modified,
    },
    body: text,
  });
  if (response.status === 412) {
    return { conflict: true, edited: text, latest: await readNote(saved.url) };
  }
  if (!response.ok) throw new Error(`Write failed: ${response.status}`);
  return { conflict: false, latest: await readNote(saved.url) };
}
```

The important part is using the date from the original read. If you did a fresh GET right before the PUT, you'd pick up someone else's changes and overwrite them anyway. On conflict, the function keeps the user's text so the UI can offer a merge, rather than quietly resubmitting with the newer date. Cross-origin, JavaScript can read `Last-Modified` without extra configuration because it's a CORS-safelisted response header (ETag isn't), though the request itself still needs CORS permission. See the [Fetch Standard](https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name).

## Server Implementation

Ignore the header in three cases: the request also has `If-Match`, the value isn't a valid HTTP-date (a list of dates counts as invalid), or the resource has no modification date. An invalid date means "skip the check", not "reject the write".

Run your normal request checks first. Per [RFC 9110 §13.2.1](https://www.rfc-editor.org/rfc/rfc9110#section-13.2.1), any error or redirect the request would get without the condition takes priority over a 412.

Compare at the same precision you advertise. A classic bug: the database stores milliseconds, `Last-Modified` is rounded to whole seconds, and a client that sends back the date it just received gets a 412. And do the comparison and the write atomically, or two writers can both pass the check.

Django's [`condition()` decorator](https://docs.djangoproject.com/en/5.2/topics/conditional-view-processing/) handles date parsing, ETag precedence, and the 412 for you. This view assumes a `Document` model with `content` (text) and `updated_at` (a timezone-aware datetime). It replaces the content with UTF-8 text and locks the row before checking the condition:

```python
from hashlib import sha256
from django.db import transaction
from django.http import HttpResponse, HttpResponseNotAllowed
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views.decorators.http import condition
from .models import Document

def replace_document(request, pk):
    if request.method != 'PUT':
        return HttpResponseNotAllowed(['PUT'])
    if request.content_type != 'text/plain':
        return HttpResponse(status=415)
    try:
        replacement = request.body.decode('utf-8')
    except UnicodeDecodeError:
        return HttpResponse(status=400)

    with transaction.atomic():
        document = get_object_or_404(
            Document.objects.select_for_update(), pk=pk)

        def etag(request):
            return sha256(document.content.encode('utf-8')).hexdigest()

        def modified(request):
            return document.updated_at

        @condition(etag_func=etag, last_modified_func=modified)
        def save(request):
            document.content = replacement
            document.updated_at = timezone.now()
            document.save(update_fields=['content', 'updated_at'])
            return HttpResponse(status=204)

        return save(request)
```

Put your authentication and permission checks before the transaction. The view runs the ordinary method, media-type, decoding, and not-found checks first, so those come back as their own errors, not as 412s. The decorator's callbacks read the same locked row the inner view changes, and if the condition fails, `save` never runs.

[`select_for_update()`](https://docs.djangoproject.com/en/5.2/ref/models/querysets/#select-for-update) holds the row lock until the transaction ends, on databases that support it. On SQLite it does nothing, so this approach needs a database with row locking, and every writer has to follow the same locking rules. If you wrapped only the final `save()` in `atomic()`, another write could slip in between the date check and the lock.

The matching GET has to send `Last-Modified` from `document.updated_at`, and any ETag it sends must come from the same UTF-8 bytes. Using the time of the GET as `Last-Modified` is a common mistake; that's what the `Date` header is for. `Last-Modified` records when the content changed. The 204 from the PUT carries no validators, so the client refetches before editing again.

Here's that GET view. It reads the document once and derives both validators from that one read:

```python
def read_document(request, pk):
    if request.method not in ('GET', 'HEAD'):
        return HttpResponseNotAllowed(['GET', 'HEAD'])
    document = get_object_or_404(Document, pk=pk)
    data = document.content.encode('utf-8')

    @condition(
        etag_func=lambda request: sha256(data).hexdigest(),
        last_modified_func=lambda request: document.updated_at,
    )
    def send(request):
        return HttpResponse(
            b'' if request.method == 'HEAD' else data,
            content_type='text/plain; charset=utf-8',
            headers={'Content-Length': str(len(data))},
        )

    return send(request)
```

The decorator adds the validators to GET and HEAD responses. Wire both views to your URLs however your app dispatches methods. Keep `send` from querying the document again; a second read could return a newer revision than the one the validators describe. The view encodes the content before checking the condition, so a 304 saves the transfer but not that work.

## Getting If-Unmodified-Since right

HTTP dates only go down to the second. Two edits in the same second can share a `Last-Modified` value, so a matching date doesn't mean the content is the same. When your application has a strong ETag, use `If-Match` instead.

Keep date parsing apart from the precondition decision. If one broad `try`/`except` wraps both, it can swallow the failed-precondition exception and let the write go through.

For a document last modified at `Mon, 05 Oct 2026 09:00:01 GMT`, here's how each request plays out:

| Request field | Result of this date check |
| --- | --- |
| Date at 09:00:00 | False: normally 412 |
| Date at 09:00:01 | True |
| Date at 09:00:02 | True |
| Invalid date | Ignore this field |
| If-Match also present | Ignore this field; evaluate If-Match |

Passing this check only gets the request past this one condition; other checks still apply. Note the 09:00:02 row: a date later than the real one lets an intervening change slip through. That's another reason to send back the server's exact `Last-Modified` instead of building a date on the client.

## If-Unmodified-Since vs If-Match

Sending both headers doesn't make the check stricter: **If-Match replaces this check**. If-Unmodified-Since passes when the modification date is earlier than or equal to yours; If-Match needs a strong ETag match. If-Range is different again: its date has to match exactly and qualify as a strong validator.

## Testing If-Unmodified-Since

Use a throwaway resource on a server that supports conditional writes. Take the date from the GET's `Last-Modified`, not from the current time:

```bash
curl -sS -D - https://example.com/notes/current
curl -i -X PUT https://example.com/notes/current \
  -H 'If-Unmodified-Since: Mon, 05 Oct 2026 09:00:00 GMT' \
  -H 'Content-Type: text/plain' --data-binary 'hello'
```

Replace the placeholder date with the real value from the response. To see a failure, change the resource so its modification date moves forward, then send another write with the old date. You should get a 412, and the resource should be unchanged. In a real client, refetch and resolve the conflict before retrying.

Test precedence separately. On an endpoint that sends both validators, pair the current strong ETag with a deliberately old date:

```bash
curl -i -X PUT https://example.com/notes/current \
  -H 'If-Match: "current-tag"' \
  -H 'If-Unmodified-Since: Sat, 01 Jan 2000 00:00:00 GMT' \
  -H 'Content-Type: text/plain' --data-binary 'replacement'
```

Use the real current tag. Because `If-Match` is present, the old date should be ignored and the write should succeed. Then try the stale date on its own, and an invalid date on its own. Use a throwaway note for that last one: an invalid date is ignored, so the write goes through.

## Related Headers

- [Last-Modified](https://howhttpworks.com/headers/last-modified)
- [If-Modified-Since](https://howhttpworks.com/headers/if-modified-since)
- [If-Match](https://howhttpworks.com/headers/if-match)
- [If-Range](https://howhttpworks.com/headers/if-range)
