Introduction Spaghetti code is hard to work with because its logic is tangled. A function in Python can handle several related steps and still be perfectly readable. Problems start when different responsibilities become tightly connected, dependencies are unclear, and changing one piece of logic requires tracing through unrelated parts of the code. Breaking code into focused functions can help reduce that complexity. A good Python function should have a clear purpose, accept well-defined inputs, and produce an understandable result. This makes individual pieces easier to read, test, debug, and modify. Python gives you plenty of freedom in how you structure your code, which makes these habits especially important. Learning how to separate responsibilities and keep relationships between pieces of logic clear is a practical way to move toward cleaner, more maintainable Python. This article covers: What messy, tangled code looks like in an example you can actually run How to split a function into small, focused pieces How to model order data with a data class instead of a dictionary How to raise errors instead of silently printing warnings How to test the resulting functions independently, and how to apply the same pattern to your own code We'll work through one script from its not-so-maintainable state to a cleaner version, step by step. You can find the code on GitHub. Spotting the Signs of Messy Code Here's a small order-processing function for an online store. It calculates a discount, updates stock, and sends an email, all inside one function. inventory = {"sku-1042": 18, "sku-2077": 4} def process_order(order): total = 0 for item in order["items"]: price = item["unit_price"] * item["quantity"] if order["customer_type"] == "vip": price = price * 0.85 elif order["customer_type"] == "regular" and total > 100: price = price * 0.95 total += price if item["sku"] in inventory: inventory[item["sku"]] -= item["quantity"] else: print(f"Warning: {item['sku']} not found in inventory") if total > 500: shipping = 0 else: shipping = 12.99 total += shipping print(f"Sending confirmation email to {order['customer_email']}") print(f"Order total: ${total:.2f}") return total process_order calculates pricing, applies a discount, mutates the global inventory dict, decides on shipping, and simulates sending an email — all in the same loop. There's also a bug buried in there: the regular-customer discount checks total > 100 partway through the loop, so whether a customer gets the discount depends on the order items happen to appear in, and not on the finished order total. That kind of bug is easy to miss because everything is mixed together. ⚠️ Here are the signs to watch for in your own code: a function whose name doesn't match everything it does, a variable that changes meaning as you move down the function, and any calculation that depends on the order statements happen to execute in. Splitting One Function Into Focused Pieces Give each responsibility its own function, with a clear input and a clear return value. No mutation of shared state from inside a loop, and no calculation that depends on execution order. def calculate_subtotal(items): return sum(item.unit_price * item.quantity for item in items) def apply_discount(subtotal, customer_type): if customer_type == "vip": return subtotal * 0.85 if customer_type == "regular" and subtotal > 100: return subtotal * 0.95 return subtotal def calculate_shipping(discounted_total): return 0.0 if discounted_total > 500 else 12.99 Each function here takes plain values in and returns a plain value out. apply_discount now checks the finished subtotal instead of a running total, which removes the ordering bug as a direct result of separating the calculation from the loop. You can call any of these three functions on its own and know exactly what it does, without running the rest of the script. Replacing Dictionaries With a Data Class Passing around dictionaries with string keys works, but it gives no guarantee about what fields exist or what type they hold. Data classes fix that by giving the order and its items a defined structure. from dataclasses import dataclass @dataclass class OrderItem: sku: str unit_price: float quantity: int @dataclass class Order: customer_email: str customer_type: str items: list[OrderItem] With these in place, the remaining pieces can be written against a known shape instead of guessing at dictionary keys: def process_order(order: Order, inventory: dict) -> float: subtotal = calculate_subtotal(order.items) discounted = apply_discount(subtotal, order.customer_type) total = discounted + calculate_shipping(discounted) update_inventory(order.items, inventory) return total process_order is now a coordinator rather than a worker; it calls each step in sequence and returns the result. Reading it top to bottom tells the whole story of handling an order: calculate, discount, ship, update stock. Read Python Data Classes Beyond the Boilerplate to learn more. Raising Errors Instead of Printing Warnings The original function printed a warning when a SKU wasn't found and kept going. That means a missing SKU never actually stops anything; it only logs a line that's easy to miss in a busy terminal. def update_inventory(items, inventory): for item in items: if item.sku not in inventory: raise ValueError(f"{item.sku} not found in inventory") inventory[item.sku] -= item.quantity Raising an exception makes the failure explicit at the point where it occurs. This prevents the order from continuing when the inventory update has not completed successfully. It also makes the issue easier to detect during testing and easier to trace when debugging. Testing Each Piece on Its Own Once logic is split into small functions, testing them stops requiring the whole pipeline to run: def test_apply_discount_vip(): assert apply_discount(200, "vip") == 170.0 def test_apply_discount_regular_under_threshold(): assert apply_discount(80, "regular") == 80 You can also use pytest to make this direct. If apply_discount breaks, the failing test points straight at the discount rule. Compare that to the original single function, where a bug report would just say the order total looked wrong, with no indication of which of its four responsibilities was at fault. Adding type hints to these functions, as shown in process_order above, extends this further — a linter can catch a caller passing a dictionary where an Order is expected before the code ever runs. Read Beginner's Guide to Unit Testing Python Code with pytest for an introduction to pytest. Applying This to Your Own Code The pattern in this tutorial applies to any function that's grown past one job. Next time you open a function you're avoiding, work through it in this order: List every distinct thing the function does, in plain language, one item per line. Pull each item into its own function that takes plain arguments and returns a plain value. Replace any dictionary being passed around with a data class, so the shape of the data is explicit. Replace print-and-continue error handling with an exception that stops execution. Write one test per extracted function before moving on to the next one. Doing this on one function at a time, instead of rewriting a whole file at once, keeps the change reviewable and keeps the script working at every step. Summary Here's a quick reference for the changes covered in this tutorial and what each one buys you: Problem in the original code Fix applied What it gives you One function handling several unrelated responsibilities Split the function into smaller ones, one responsibility each Each piece can be read, changed, and tested on its own A calculation that depended on the order statements happened to run in Based the calculation on a finished value instead of one still changing mid-loop Removes bugs caused by execution order rather than actual logic Data passed around as a loose dictionary Modeled the data with a dataclass Makes the available fields and types explicit, and lets a linter catch mismatches An error logged with print while execution continued Raised an exception instead Surfaces the problem immediately instead of letting execution continue No way to test one piece of logic without running the whole script Added a focused test for each extracted function A failing test points directly at the broken piece Further reading: The Art of Writing Readable Python Functions Python Testing With pytest Refactoring Python Applications for Simplicity Bala Priya C is a developer and technical writer from India. She likes working at the intersection of math, programming, data science, and content creation. Her areas of interest and expertise include DevOps, data science, and natural language processing. She enjoys reading, writing, coding, and coffee! Currently, she's working on learning and sharing her knowledge with the developer community by authoring tutorials, how-to guides, opinion pieces, and more. Bala also creates engaging resource overviews and coding tutorials.
From Spaghetti Code to Clean Python: A Beginner’s Guide
Full Article
Original Source
Read the full article at Kdnuggets →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.