Here is a problem I keep running into. We take a few optional inputs from the user. Say a profile form with nickname, bio, and website. For each field, the user can do one of three things:

  • Leave it untouched, never type anything into it.
  • Clear it out on purpose, submit it blank.
  • Fill it in with a real value.

These are three different intentions, and the service that saves the form has to respect all three. Untouched should leave whatever is already stored. Blank should wipe it. And a real value should be saved as is.

Now look at the function in our service class that does the update:

def update_profile(user, nickname=None, bio=None, website=None):
    user.nickname = nickname
    user.bio = bio
    user.website = website
    user.save()

The default is None. But here is the catch. None is itself a real value the user can land in. And so is the empty string "" when they clear a field. So neither one can stand in for “the user didn’t touch this field”. They are both things the user might genuinely mean.

So with None as the default, a field the user never touched and a field the user cleared on purpose both arrive as None. The function can’t tell them apart. It just overwrites everything, including the fields the user never meant to change.

What we actually need

What we need is a separate marker. Something that means “not provided” and that the user can never send. Not None, not "", not anything a form can produce. That marker is called a sentinel.

And in Python 3.15 there is now a builtin to make one. It comes from PEP 661, it’s already in the 3.15 alpha releases, and you use it like this:

NOT_PROVIDED = sentinel('NOT_PROVIDED')

No import needed. It’s a builtin, just like print or len. NOT_PROVIDED is now a unique value, and nothing else is is-equal to it. So it’s safe to use as a default even when None and "" are valid inputs. Now our service can check each field on its own:

NOT_PROVIDED = sentinel('NOT_PROVIDED')


def update_profile(user, nickname=NOT_PROVIDED, bio=NOT_PROVIDED, website=NOT_PROVIDED):
    if nickname is not NOT_PROVIDED:
        user.nickname = nickname
    if bio is not NOT_PROVIDED:
        user.bio = bio
    if website is not NOT_PROVIDED:
        user.website = website
    user.save()

And the caller passes only the fields that were actually in the request:

payload = {}
for field in ("nickname", "bio", "website"):
    if field in request.data:
        payload[field] = request.data[field]   # could be "" or a real value

update_profile(user, **payload)

Now all three intentions survive:

  • A field the user never touched isn’t in request.data, so it never enters payload, so it stays NOT_PROVIDED and the service skips it. The stored value is left alone.
  • A field the user cleared is in request.data as "", so it gets passed through and saved as blank.
  • A field the user filled in is passed through and saved as the value.

That is NOT_PROVIDED check is the whole trick. It separates “leave it alone” from “set it to blank”. None and "" could never do that for us, because the user can mean both of them.

Some APIs go one step further and let the user send an explicit null, to mean “set this to nothing”, separate from a blank "". That works here too, for free. An explicit null just arrives as None, which is another value the function happily saves. Because our default is the sentinel and not None, all four states stay distinct. Not provided stays NOT_PROVIDED. An explicit null is None. A cleared field is "". And a filled field is its value. That’s the real reason None can’t be the default. The moment the user is allowed to send it, it stops being safe as our “not provided” marker.

What the sentinel gives us

Being unique is only half of it. A sentinel() value is also nice to live with:

  • It has a clean repr. print(NOT_PROVIDED) shows NOT_PROVIDED, not some hex address. So our logs and tracebacks stay readable.
  • It survives copy and pickle. copy.deepcopy(NOT_PROVIDED) is NOT_PROVIDED is True, and so is a pickle round trip, as long as the sentinel lives at module level. The is check keeps working across all of it.
  • It has a type, so type checkers understand it.

That last one is the nice part. Type checkers recognize the pattern NAME = sentinel('NAME') as its own type. So we can finally describe the field as “a string, or not provided”:

def update_profile(user, nickname: str | NOT_PROVIDED = NOT_PROVIDED):
    if nickname is NOT_PROVIDED:
        ...      # the checker knows nickname is NOT_PROVIDED here
    else:
        ...      # and a str here

The checker narrows the type on each branch, the same way it already does with None.

And if the bare name isn’t descriptive enough, you can pass a custom repr:

NotGiven = sentinel('NotGiven', repr='MyClass.NotGiven')

Wrapping up

So whenever you have fields where None and "" are both meaningful, you need a sentinel to mark “not provided”. There’s really no way around it.

The good part is that on Python 3.15 and newer, sentinel() is right there in the language. One line gives you a safe is check, a readable repr, working copies and pickles, and proper type hints.


Further reading: