
In the last article, we went through a self-assessment to figure out where you really are on your DevOps Python journey. Be honest, did you find yourself in the "Beginner" or "Advanced Beginner" camp? If so, you're in the right place. This is where the real work begins, and it's the most critical transition you'll make in your career.
You’ve gotten things to work. You’ve probably copy-pasted a snippet from Stack Overflow, tweaked a script you found in a tutorial, or used ChatGPT to generate a function that automates a tedious task. And when it finally ran successfully? It felt great. That "aha!" moment is definitely a great feeling.
Until the code breaks a week later. Or when a teammate tries to run it and it crashes their machine. Or worse, when it fails silently in the middle of a CI/CD pipeline, and nobody notices until the deployment is already a smoking crater. "Making it work" only takes you so far. It’s a world of temporary fixes and fragile scripts. To grow, you need to leave it behind.
The Mindset Shift: From "Make It Work" to "Make It Work Reliably"

The single biggest leap a junior engineer makes is shifting their primary goal. Your job is no longer just to solve an immediate problem; it's to create a solution that doesn't become tomorrow's problem.
This isn’t about becoming a "Python guru" overnight. It’s about building a foundation of reliability. It’s about respecting the time of the people who will have to deal with your code when it fails - including your future self at 3 AM. Every skill we'll cover here is a step away from fragile, one-off scripts and a step toward professional, trustworthy automation. This is how you start building a reputation for being a dependable engineer, not just someone who can "do a little Python."
Skill 1: Intentional Error Handling
The first sign of a junior script is how it behaves when something goes wrong. If the answer is "it just crashes," you have work to do. Automation that isn't predictable is just another problem waiting to happen.
Most beginners learn try...except and immediately start doing this:
# junior_approach_error_handling.py
try:
# 15 lines of complex logic here...
config = read_config("settings.yaml")
directory = config["cleanup_dir"]
# ... more operations
except Exception as e:
print(f"An error occurred! {e}")This is better than nothing, but it's like using a sledgehammer to hang a picture frame. By catching the generic Exception, you’re swallowing every possible error, including bugs in your own code or even the user hitting Ctrl+C (KeyboardInterrupt). It hides the real problem, and seldom provides reliable information about what actually went wrong.
Professional error handling is specific. You anticipate the likely points of failure and handle them explicitly.
# better_approach_error_handling.py
from pathlib import Path
try:
config = read_config("settings.yaml")
directory = config["cleanup_dir"]
except FileNotFoundError:
print("Error: Configuration file 'settings.yaml' not found.")
exit(1) # Exit with a non-zero code to signal failure
except KeyError:
print("Error: 'cleanup_dir' not defined in the configuration.")
exit(1)
# Check if the directory actually exists
directory_path = Path(directory)
if not directory_path.exists():
print(f"Error: Directory '{directory}' does not exist.")
exit(1)
# Now, we can proceed knowing the directory is valid
print(f"Successfully configured to clean directory: {directory}")This version tells you exactly what went wrong. It handles specific, expected problems (a missing file, a missing key, nonexistent directories) and lets unexpected errors crash loudly so you can find and fix the underlying bug. The exit(1) is also a critical detail for automation: it tells shell scripts and CI/CD systems that this step failed.
Writing code that fails gracefully demonstrates foresight. It shows you’re thinking about edge cases and not just the happy path. This builds trust with your senior engineers because they know your automation won't just fall over at the first sign of trouble.
Skill 2: Logging That Actually Helps
If error handling is about what your script does when it fails, logging is about how it tells the story of what it was trying to do. Using print() for debugging feels intuitive at first, but it’s a dead end.
Why? print() statements have no context. They don’t have timestamps. They don’t have severity levels. In a CI/CD pipeline that produces thousands of lines of output, your print("I'm here!") is completely lost noise. When your script fails, you have no breadcrumb trail to follow. You can't tell the difference between a simple progress update and a critical failure warning.
Python's built-in logging module is the answer. It’s designed for this exact problem. It allows you to add levels, timestamps, and redirect output to files, all with a few lines of configuration.
Let's refactor a simple script that uses print().
# junior_approach_logging.py
def user_is_valid(user):
return user in ["alice", "bob", "charlie", "admin"]
def process_users(user_list):
print("Starting user processing...")
for user in user_list:
print(f"Processing user: {user}")
if not user_is_valid(user):
print(f"Warning: Invalid user '{user}' found, skipping.")
print("User processing finished.")
# Somewhere else...
process_users(["alice", "bob", "bad-user"])Now, let's upgrade this to use the logging module.
# better_approach_logging.py
import logging
# Basic configuration: level, format
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
def user_is_valid(user):
return user in ["alice", "bob", "charlie", "admin"]
def process_users(user_list):
logging.info("Starting user processing...")
for user in user_list:
logging.debug(f"Processing user: {user}") # More verbose, good for debugging
if not user_is_valid(user):
logging.warning(f"Invalid user '{user}' found, skipping.")
logging.info("User processing finished.")
# Somewhere else...
process_users(["alice", "bob", "bad-user"])This is a game-changer. Your output now looks like this:
2025-09-05 09:11:00,123 - INFO - Starting user processing...
2025-09-05 09:11:00,124 - WARNING - Invalid user 'bad-user' found, skipping.
2025-09-05 09:11:00,125 - INFO - User processing finished.You instantly have timestamps and severity levels. You can configure your logging level to hide the noisy DEBUG messages in production but turn them on when you're troubleshooting.
Good logging is a gift to your team. It makes your automation auditable and debuggable. When a senior engineer has to investigate a production issue at midnight, finding clear, informative logs from your script is a massive relief. It shows you are a professional who builds tools for a team, not just for yourself.
Skill 3: Environment Awareness (Stop Hardcoding! 😡)
The phrase "it works on my machine" is a meme for a reason. It's the hallmark of a junior developer who hasn't yet realized that code runs in many different contexts. Hardcoding API keys, file paths, or server names might look like the fastest way to get there, but it's also the fastest way towards a lot of noise, mess, and god forbid, leaked secrets.
Your code must be separated from its configuration. The most straightforward way to do this for DevOps automation is through environment variables (although definitely not the most scalable). Python makes this trivial with the os module.
# junior_approach_environment.py
API_URL = "https://api.production.myapp.com/v1"
API_TOKEN = "ghp_123abc456def..." # Uh oh, a secret in code
def call_api():
# ... uses API_URL and API_TOKEN directly
print(f"Calling production API at {API_URL}")This is rigid and insecure. Let’s make it flexible.
# better_approach_environment.py
import os
import sys
# Get config from environment variables
# .get() is safer than os.environ[...] because it returns None if not found
API_URL = os.getenv("API_URL", "https://api.production.myapp.com/v1")
API_TOKEN = os.getenv("API_TOKEN")
if not API_TOKEN:
print("Error: API_TOKEN environment variable is not set.")
sys.exit(1)
def call_api():
print(f"Calling API at {API_URL}")Now, you can run the same script in different environments just by changing the variables:
$ API_URL="https://api.staging.myapp.com" API_TOKEN="abc" python my_script.py
$ API_URL="https://api.prod.myapp.com" API_TOKEN="xyz" python my_script.pyThis is how you build Docker containers, configure CI/CD jobs, and run automation in Kubernetes. You’ve decoupled the logic of your script from the configuration of the environment it runs in.
This skill shows you understand a core tenet of DevOps. You see your script as a component within a larger system. Engineers who do this are immediately more useful because their work can be integrated into deployment pipelines and production environments without needing code changes.
Skill 4: The Testing Mindset (Before the Frameworks)
Most juniors think of testing as something that requires a big, complicated framework like pytest or unittest. While those are essential later, the most important step is simply starting to write code that is testable.
Yes, I'm talking about that 100-line behemoth of a script that reads a file, makes three different API calls, processes the data, and then updates a database. How do you test it? You just don't 😆. You have to run the whole thing and hope for the best. If you want to test just the data processing logic, you're out of luck: it's tangled up with network requests and database writes.
The secret is to write small, pure functions as much as possible. A "pure function" is a simple concept: its output depends only on its inputs, and it has no side effects (like printing, writing to a file, or calling an API). While this is not always possible (we have to write stuff to databases, right?), the mindset of trying to design your code around purity can go a long way in improving its structure.
# junior_approach_untestable.py
def generate_report():
# This function does EVERYTHING
raw_data = requests.get("https://api.metrics.com/data").json()
# Complex processing logic is mixed in
processed_metrics = {}
for item in raw_data['items']:
if item['status'] == 'active':
key = item['name'].lower()
processed_metrics[key] = item['value'] * 1.15
db.save_report(processed_metrics)
print("Report saved.")This is impossible to test without a real API and database (or a lot of mocking). Let’s fix it by separating the concerns.
# better_approach_testable.py
def process_raw_data(raw_data: dict) -> dict:
"""A pure function. Output only depends on input."""
processed_metrics = {}
for item in raw_data.get('items', []):
if item.get('status') == 'active':
key = item['name'].lower()
processed_metrics[key] = item['value'] * 1.15
return processed_metrics
def generate_report():
"""This function now handles the 'side effects'."""
raw_data = requests.get("https://api.metrics.com/data").json()
processed_metrics = process_raw_data(raw_data) # Call our testable function
db.save_report(processed_metrics)
logging.info("Report saved.")Look at process_raw_data. It’s simple, predictable, and isolated. You can now easily check if it works without needing a network connection or a database. You can just give it some sample data and check the output. You have adopted a testing mindset.
Writing testable code is a sign of maturity. It makes your code easier to read, easier to debug, and much easier to review. A senior engineer looking at your pull request will immediately see the value in this separation of concerns. It proves you're not just writing a script; you're engineering a solution.
Your Junior-Level Action Plan
It's time to put this into practice. Don't just read it: do it.
Embrace Better Error Handling: Find one of your existing scripts. Identify one specific place it could fail (a missing file, a bad network response), and add a specific
try...exceptblock for that error. Log a clean, human-readable error message.Ditch
print()forlogging: Take that same script and replace every debuggingprint()statement with aloggingequivalent. Uselogging.info()for progress andlogging.warning()orlogging.error()for problems. ConfigurebasicConfigat the top of your script.Externalize Your Configuration: Find a script where you've hardcoded a URL, filename, or token. Refactor it to pull that value from an environment variable using
os.getenv(). Add a check to make sure the variable is actually set.Isolate One Piece of Logic: Pick a script that does multiple things. Find the core "thinking" part of it-the data transformation, the filtering, the business logic. Pull that logic out into its own separate, pure function. Your main script will then call this function.
Here are a couple of resources to get you started on many of the topics we discussed in this article:
What's Next?
By mastering these skills, you’ll be writing automation that is reliable, debuggable, and configurable. You’ll have moved firmly beyond the copy-paste stage and are now on the path to being a truly effective DevOps engineer.
But what happens when your scripts get more complex? How do you manage resources like file handles and network connections safely? How do you build code that your teammates can easily import and reuse?
In the next article, "Building Python Code Your Teammates Actually Want to Work With," we will bridge the gap to the mid-level. We'll dive into the skills that turn your reliable scripts into professional, reusable tools that solve entire classes of problems. See you soon!
