Character sets, groups, and more re functions

Character sets: [...]

Square brackets match any one of the characters inside them:

re.fullmatch(r"[abc]", "b")        # matches -- 'b' is one of a, b, c
re.fullmatch(r"[A-Za-z]", "Q")     # matches -- A-Z or a-z, any single letter
re.fullmatch(r"[A-Za-z0-9_]+", "user_123")   # matches -- letters, digits, or underscore, one or more

A-Z inside [] is a range, not the literal characters "A", "-", "Z" — it means "any character from A to Z". You can combine several ranges and individual characters in one set, as in [A-Za-z0-9_] above.

Lookahead: (?=...)

A lookahead checks that something follows the current position, without consuming it (without it counting as part of the match itself):

re.sub(r"\d(?=\d{4})", "*", "1234567890")
# "******7890" -- every digit that has at least 4 more digits after it gets replaced

This is a more advanced tool — you won't need it often, but it's exactly the right tool for "mask everything except the last N characters", which shows up in one of the problems ahead.

Useful re functions

re.findall(r"\d+", "a1 b22 c333")   # ['1', '22', '333'] -- every non-overlapping match, as a list
re.sub(r"\d", "#", "a1b2c3")         # "a#b#c#" -- replace every match with a string

re.findall is what you reach for when you want every match in a string, not just whether one exists. re.sub ("substitute") replaces every match with a replacement string, similar to .replace() but with a pattern instead of a fixed substring.

Putting it together

import re

def is_valid_zip(s):
    return re.fullmatch(r"\d{5}", s) is not None

re.fullmatch(...) returns a match object (truthy) if it matches, or None (falsy) if it doesn't — is not None converts that into an explicit True/False, which is the pattern you'll use in most of the validation problems ahead.