Create your own
Lesson illustration

Writing Idiomatic Python with Control Flow and Exception Handling

Hello again. In the previous lesson, you created a Python workspace with an isolated virtual environment, Black formatting, and the VS Code debugger. You also used the debugger to find a runtime type mismatch. This lesson turns that small script into reusable code: functions that make decisions, process repeated inputs, and handle expected failures without hiding real defects.

By the end, you will be able to write a small Python “request router” similar to a component inside an AI application: it validates a user-provided priority, chooses a processing queue, and safely rejects malformed input. The focus is practical Python rather than mathematical concepts.


Functions are small, reusable contracts

A function bundles instructions that perform one named job. Rather than repeating logic throughout a program, you define it once and call it wherever needed.

In Python, a function is defined with def, followed by a name, parentheses for parameters, and a colon:

def build_status_message(status):
    """Return a display message for a request status."""
    return f"Request status: {status}"

Calling the function executes its body:

message = build_status_message("accepted")
print(message)

Output:

Request status: accepted

A useful way to read a function is as a contract:

  • Inputs: the arguments provided by the caller, such as status
  • Work: the logic inside the function
  • Output: the value after return

return is different from print():

def calculate_retry_delay(attempt_number):
    return attempt_number * 2

This function returns a number. The caller can print it, store it, compare it, or pass it to another function:

delay = calculate_retry_delay(3)
print(f"Retry after {delay} seconds")

For application code, especially future API and agent code, prefer returning values from internal functions. Put print() near the outer boundary of a command-line program. Later, an API endpoint will return a response rather than print to the terminal.

A function ends when it reaches return. If no return is reached, Python returns None, its “no value” object.

def log_startup():
    print("Application started")

result = log_startup()
print(result)

The output is:

Application started
None

Python and Java: the useful differences

You already know the core idea of methods from Java. The main differences here are syntactic and structural:

IdeaJavaPython
Standalone reusable behaviorUsually a method inside a classA function can live directly in a module
Code blocksCurly bracesIndentation
Function declarationpublic String format(String text)def format_text(text):
Multiple brancheselse ifelif
Type enforcementMany checks occur at compile timeActual operations are checked at runtime
Checked exceptionsSome exceptions must be declared or handledPython does not require checked-exception declarations

Python is dynamically typed but strongly typed. A variable may refer to different types at different times, but Python will still reject nonsensical operations at runtime, such as adding a number to text.

That is why the debugger in the previous lesson showed a TypeError when comparing an integer to "6".

Python blocks use a colon and indentation, not braces:

def choose_queue(priority):
    if priority >= 5:
        return "urgent"
    return "standard"

Black will keep the indentation consistent, but the logical structure is still yours to read carefully: four spaces deeper means “inside this block.”

Python Tutorial for Beginners 8: Functions

Watch “Python Tutorial for Beginners 8: Functions” by Corey Schafer for a visual introduction to defining, calling, and returning from functions. It also demonstrates parameters and default values, which you will use in the request-router example.

Watch function basics. Focus on the distinction between referring to a function and calling it with parentheses, then on why a returned value is more reusable than a printed value. In the final portion, notice that required parameters come before parameters with defaults.


Make decisions with if, elif, and early returns

Programs need to choose behavior based on values. A condition evaluates to either True or False.

def choose_queue(priority):
    if priority == 5:
        return "urgent"
    elif priority >= 3:
        return "priority"
    else:
        return "standard"

This function checks the branches from top to bottom. As soon as one condition is true, that branch runs and the remaining branches are skipped.

A common Python style is to use early returns when they make the normal path clearer:

def choose_queue(priority, is_security_issue=False):
    if is_security_issue:
        return "security-review"

    if priority == 5:
        return "urgent"

    if priority >= 3:
        return "priority"

    return "standard"

This reads as a sequence of business rules:

  1. Security issues always go to security review.
  2. Highest-priority requests are urgent.
  3. Medium-to-high priorities are priority work.
  4. Everything remaining is standard.

The default value False means callers can use the simple call:

queue = choose_queue(4)

Or make the optional behavior explicit with a keyword argument:

queue = choose_queue(4, is_security_issue=True)

Keyword arguments improve readability when a Boolean flag would otherwise be unclear.

A few conditions worth knowing

Use == to compare values:

if queue == "urgent":
    print("Escalate immediately")

Use is None to check specifically for the absence of a value:

if priority is None:
    print("Priority was not valid")

Do not generally use is for ordinary number or string comparisons. is checks whether two names refer to the same object, while == checks whether values are equal.

Python also supports familiar Boolean operators:

if is_logged_in and is_admin:
    print("Show administration tools")

if is_security_issue or priority == 5:
    print("Use fast escalation path")

if not has_permission:
    print("Access denied")

Empty strings, empty collections, zero, False, and None are treated as false in a condition. That permits concise checks such as:

if not user_message.strip():
    print("Message cannot be empty")

The .strip() call removes surrounding spaces first, so a message containing only spaces is also rejected.

4. More Control Flow Tools — Python 3.14.0 documentation

Read the relevant parts of “More Control Flow Tools” from the official Python documentation. It is a compact reference for the exact behavior of conditionals, loops, function definitions, and return.

In Section 4.1, “if Statements,” read the conditional introduction, paying attention to why elif avoids deeper indentation. Continue into Section 4.2, “for Statements,” from iteration over items; Python for loops normally iterate directly over values rather than manually managed indexes. Then read Section 4.3, “The range() Function,” beginning with using range. In Section 4.4, “break and continue Statements,” read loop control. Finally, in Section 4.8, “Defining Functions,” read from function structure and docstrings. Focus on the relationship between parameters, local variables, return, and a short docstring.


Repeat work with for, continue, and break

Python’s for loop usually means “for each item in this sequence.”

priorities = ["2", "not-a-number", "5"]

for raw_priority in priorities:
    print(raw_priority)

Unlike the traditional Java index-based for loop, Python code often does not need an index. When you do need numbers, use range():

for attempt_number in range(1, 4):
    print(f"Attempt {attempt_number}")

This prints 1, 2, and 3. The end number is excluded.

Two loop controls are useful:

  • continue skips the rest of the current iteration and moves to the next item.
  • break exits the loop completely.

Here is a small search helper:

def first_urgent_priority(raw_values):
    """Return the first valid urgent priority, or None if there is none."""
    for raw_value in raw_values:
        if raw_value == "invalid":
            continue

        if raw_value == "5":
            return 5

    return None

continue ignores the known bad marker. return 5 ends both the loop and the function because the function has found the result it was searching for.

Use break when you want to stop looping but still perform work afterwards:

for raw_value in ["2", "3", "STOP", "5"]:
    if raw_value == "STOP":
        break

    print(f"Processing {raw_value}")

print("Batch finished")

In production systems, a stop marker might be a cancellation request, a configured processing limit, or a signal that a batch has ended.


Exceptions: handle expected failures, not every failure

An exception is Python’s way of signaling that normal execution could not continue. For example:

priority = int("high")

This raises a ValueError because "high" is not valid whole-number text.

An exception is not automatically a sign that the entire program is broken. User input, network calls, file access, and external APIs can fail in expected ways. The important question is:

Can this function recover safely and meaningfully from this specific failure?

If yes, catch the specific exception. If not, allow it to surface while developing so the traceback and debugger can show the real defect.

The basic structure is:

try:
    priority = int(raw_value)
except ValueError:
    return None

Only code that may raise the expected error should be inside try. Keep that block small. This makes it clear what operation might fail.

This diagram shows Python’s `try`, `except`, and `else` flow: Python runs the `try` block, executes `except` when an exception occurs, and executes `else` only when the `try` block completes without an exception.

The else block is useful for successful work that should happen only after the risky operation succeeds:

def parse_priority(raw_value):
    """Return a priority from 1 to 5, or None for invalid input."""
    if not isinstance(raw_value, str):
        return None

    cleaned_value = raw_value.strip()
    if not cleaned_value:
        return None

    try:
        priority = int(cleaned_value)
    except ValueError:
        return None
    else:
        if 1 <= priority <= 5:
            return priority
        return None

This function has a deliberately narrow contract:

  • It accepts text from a user-facing input field.
  • It returns an integer from 1 to 5 when valid.
  • It returns None for missing, non-text, non-numeric, or out-of-range input.

Notice the division of responsibilities:

  • if handles normal validation decisions, such as an empty string or a number outside the allowed range.
  • except ValueError handles the failed conversion from text to integer.
  • else holds logic that is meaningful only once conversion succeeded.

Avoid this pattern:

try:
    priority = int(raw_value)
    queue = choose_queue(priority)
    save_to_database(queue)
    send_notification(queue)
except Exception:
    print("Something went wrong")

This is dangerous because it catches almost anything, including programmer mistakes such as misspelled variable names or incorrect function calls. It hides useful tracebacks and makes defects much harder to diagnose.

Prefer this approach:

try:
    priority = int(raw_value)
except ValueError:
    return None

queue = choose_queue(priority)

Later in the course, your LLM and tool calls will need explicit handling for failures such as timeouts, rate limits, and invalid API responses. The same principle applies: catch the known exception you can recover from, record useful context, and do not pretend that an unknown defect was handled.

finally: cleanup that must happen either way

Python also provides finally, which runs whether the operation succeeds or fails:

try:
    connection = open_connection()
    use_connection(connection)
finally:
    connection.close()

You will more often use Python’s with statement for files, connections, and other managed resources later. For now, remember the intention: finally is for cleanup, not normal business decisions.

One Python function-default safety rule

Defaults such as strings, numbers, and False are safe:

def choose_queue(priority, is_security_issue=False):
    ...

Avoid using a mutable value such as a list or dictionary as a default parameter:

# Avoid this
def add_label(label, labels=[]):
    labels.append(label)
    return labels

Python creates that default list once, so later calls can unexpectedly share it. The safe pattern is:

def add_label(label, labels=None):
    if labels is None:
        labels = []

    labels.append(label)
    return labels

You will work with lists and dictionaries in depth in the next lesson. For now, retain the rule: use None instead of [] or {} as a function default when creating a fresh mutable value per call.


Code along: a small ticket router

Create a new file named ticket_router.py in the project from the previous lesson. This is a simplified version of input validation that a future AI support application might perform before routing a task to a workflow.

def parse_priority(raw_value):
    """Return a priority from 1 to 5, or None for invalid input."""
    if not isinstance(raw_value, str):
        return None

    cleaned_value = raw_value.strip()
    if not cleaned_value:
        return None

    try:
        priority = int(cleaned_value)
    except ValueError:
        return None
    else:
        if 1 <= priority <= 5:
            return priority
        return None


def choose_queue(priority, is_security_issue=False):
    """Choose a processing queue for a validated priority."""
    if is_security_issue:
        return "security-review"

    if priority == 5:
        return "urgent"

    if priority >= 3:
        return "priority"

    return "standard"


def route_ticket(raw_priority, is_security_issue=False):
    """Return a routing message for one incoming ticket."""
    priority = parse_priority(raw_priority)

    if priority is None:
        return "rejected: priority must be a whole number from 1 to 5"

    queue = choose_queue(
        priority,
        is_security_issue=is_security_issue,
    )
    return f"accepted: send to {queue}"


def first_urgent_priority(raw_values):
    """Find the first valid priority that requires urgent handling."""
    for raw_value in raw_values:
        priority = parse_priority(raw_value)

        if priority is None:
            continue

        if priority == 5:
            return priority

    return None


incoming_priorities = ["2", "five", " 5 ", "", "7"]

for raw_priority in incoming_priorities:
    print(route_ticket(raw_priority))

print(f"First urgent priority: {first_urgent_priority(incoming_priorities)}")
print(route_ticket("3", is_security_issue=True))

Run it from the VS Code terminal:

python ticket_router.py

You should see output broadly like this:

accepted: send to standard
rejected: priority must be a whole number from 1 to 5
accepted: send to urgent
rejected: priority must be a whole number from 1 to 5
rejected: priority must be a whole number from 1 to 5
First urgent priority: 5
accepted: send to security-review

Use this code to reinforce a simple tracing habit:

  1. Start with one input, such as "five".
  2. Enter route_ticket().
  3. Observe that parse_priority() receives "five".
  4. Follow the int(cleaned_value) conversion.
  5. See the ValueError enter the except block.
  6. Watch None return to route_ticket().
  7. Observe the priority is None branch return the rejection message.

If any step feels unclear, put a breakpoint on:

priority = parse_priority(raw_priority)

Then use Step Into to enter parse_priority() and inspect raw_value, cleaned_value, and priority. This is more reliable than mentally guessing which branch ran.


Key takeaways

You now have the core tools for writing small, maintainable Python functions:

  • Define functions with def, use clear snake_case names, and give them one focused responsibility.
  • Prefer return from internal functions; reserve print() for a program’s outer boundary.
  • Use if, elif, else, Boolean operators, and early returns to express decisions clearly.
  • Python for loops usually iterate directly over items; use continue to skip an item and break to stop a loop.
  • Use == for value comparison and is None for the explicit no-value case.
  • Treat exceptions as signals for exceptional failures, not as replacements for ordinary if validation.
  • Catch narrow, expected exceptions such as ValueError; avoid bare except: and broad except Exception: in normal application logic.
  • Use immutable defaults freely, but use None rather than a mutable list or dictionary as a default parameter.

Next, you will work with Python’s core data structures: lists, dictionaries, sets, tuples, comprehensions, and iterators. Those structures will make the ticket-router example much more capable, because it will be able to process and organize realistic application data.

Can't find a good explanation? Sign up and we'll make it for you

Sign up