Python Interview Questions

All Python interview questions and answers in one place. Choose a question set from the contents to review core Python, AWS automation, and DevOps topics.

Python Interview Questions & Answers

Q1
What is Python and what are its key features?
Basic

Ans: Python is a high-level, interpreted, dynamically typed, general-purpose programming language designed for readability and simplicity.

FeatureDescription
InterpretedCode runs line-by-line without a compile step
Dynamically typedVariable types are checked at runtime, not compile time
Garbage collectedAutomatic memory management via reference counting + GC
Multi-paradigmSupports OOP, functional, and procedural styles
Extensive stdlib“Batteries included” — huge standard library out of the box
Cross-platformRuns on Windows, macOS, Linux without modification
Q2
What is the difference between a list, tuple, set, and dictionary in Python?
Basic

Ans:

TypeOrderedMutableDuplicatesKey-ValueSyntax
list✅✅✅❌[1, 2, 3]
tuple✅❌✅❌(1, 2, 3)
set❌✅❌❌{1, 2, 3}
dict✅ (3.7+)✅Keys: ❌✅{"a": 1}
my_list  = [1, 2, 2, 3]      # Ordered, mutable, allows duplicates
my_tuple = (1, 2, 2, 3)      # Ordered, immutable
my_set   = {1, 2, 3}         # Unordered, no duplicates
my_dict  = {"name": "Alice", "age": 30}  # Key-value pairs

# When to use each:
# list   → ordered sequence that changes (shopping cart)
# tuple  → fixed data (coordinates, RGB values)
# set    → membership testing, deduplication
# dict   → lookup by key (configuration, JSON-like data)
Q3
What is the difference between mutable and immutable objects in Python?
Basic

Ans: Below is the difference between mutabke and immutable object:

  • Mutable objects can be changed after creation.
  • Immutable objects cannot.
MutableImmutable
listint, float, bool
dictstr
settuple
bytearrayfrozenset, bytes
# Mutable — same object modified in place
lst = [1, 2, 3]
print(id(lst))   # e.g., 140234567890
lst.append(4)
print(id(lst))   # SAME id — modified in place

# Immutable — new object created
s = "hello"
print(id(s))     # e.g., 140234567000
s = s + " world"
print(id(s))     # DIFFERENT id — new string object

# Gotcha: mutable default arguments
def add_item(item, lst=[]):   # BAD — shared across calls!
    lst.append(item)
    return lst

def add_item(item, lst=None): # GOOD
    if lst is None:
        lst = []
    lst.append(item)
    return lst
Q4
What is a Python decorator and how do you create one?
Intermediate

Ans: A decorator is a function that wraps another function to extend its behaviour without modifying the original code. It implements the Decorator design pattern.

import functools
import time

# Basic decorator
def timer(func):
    @functools.wraps(func)  # Preserves original function metadata
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"{func.__name__} took {end - start:.4f}s")
        return result
    return wrapper

@timer
def slow_function(n):
    """Simulate work."""
    return sum(range(n))

slow_function(1_000_000)
# Output: slow_function took 0.0234s
# Decorator with arguments
def retry(max_attempts=3, delay=1.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=5, delay=0.5)
def call_external_api():
    # Retries automatically on failure
    ...

Common built-in decorators:

  • @property — getter/setter on class attributes
  • @staticmethod — method that doesn’t receive self
  • @classmethod — method that receives the class (cls) instead of instance
  • @functools.lru_cache — memoization
Q5
What is a generator in Python? How does it differ from a regular function?
Intermediate

Ans:

A generator is a function that uses yield to return values lazily — one at a time — instead of computing and returning all values at once.

# Regular function — loads all values into memory
def get_squares_list(n):
    return [x**2 for x in range(n)]   # Creates list of n items in memory

# Generator — yields one value at a time (lazy)
def get_squares_gen(n):
    for x in range(n):
        yield x**2

# Memory comparison
import sys
lst = get_squares_list(1_000_000)
gen = get_squares_gen(1_000_000)

print(sys.getsizeof(lst))  # ~8 MB
print(sys.getsizeof(gen))  # 112 bytes!
# Generator expressions (like list comprehensions but lazy)
squares = (x**2 for x in range(1000))   # Generator
squares_list = [x**2 for x in range(1000)]  # List

# Infinite generator (impossible with a list)
def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

fib = fibonacci()
print([next(fib) for _ in range(10)])
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Use generators when:

  • Processing large files line-by-line
  • Producing an infinite sequence
  • Building data processing pipelines
Q6
Explain *args and **kwargs in Python.
Basic

Ans:

  • *args — collects extra positional arguments into a tuple
  • **kwargs — collects extra keyword arguments into a dict
def demo(*args, **kwargs):
    print(f"args:   {args}")    # tuple
    print(f"kwargs: {kwargs}")  # dict

demo(1, 2, 3, name="Alice", role="DevOps")
# args:   (1, 2, 3)
# kwargs: {'name': 'Alice', 'role': 'DevOps'}
# Passing to another function (forwarding)
def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with {args} {kwargs}")
        return func(*args, **kwargs)
    return wrapper

# Unpacking with * and **
numbers = [1, 2, 3]
print(*numbers)        # same as print(1, 2, 3)

config = {"sep": "-", "end": "\n"}
print(*numbers, **config)   # 1-2-3

Correct order in function signature: def f(positional, *args, keyword_only, **kwargs)

Q7
What is the Python GIL (Global Interpreter Lock) and how does it affect concurrency?
Advanced

Ans:

The GIL is a mutex in CPython that allows only one thread to execute Python bytecode at a time — even on multi-core hardware.

Thread 1: ──[GIL acquired]──[run bytecode]──[GIL released]──waiting──
Thread 2: ──waiting────────────────────────[GIL acquired]──[run]──

Impact:

  • CPU-bound tasks (number crunching) → threads don’t help; use multiprocessing instead
  • I/O-bound tasks (network, disk) → threads work fine; the GIL is released during I/O waits
import threading
import multiprocessing

# CPU-bound: multiprocessing wins (bypasses GIL — each process has its own GIL)
def cpu_work(n):
    return sum(i**2 for i in range(n))

# I/O-bound: threading works fine (GIL released during sleep/network/file I/O)
import requests
import concurrent.futures

urls = ["https://api.example.com/data"] * 10

with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
    responses = list(pool.map(requests.get, urls))
# Python 3.13+ introduces a "free-threaded" mode (experimental)
# that removes the GIL — but this is still stabilizing.

# Current best practice:
# CPU-bound → multiprocessing.Pool
# I/O-bound → asyncio or threading
# Mixed     → asyncio + run_in_executor for CPU parts
Q8
What is the difference between shallow copy and deep copy?
Intermediate

Ans: A shallow copy creates a new outer object but shares references to nested objects. A deep copy recursively copies all nested objects, making the copy completely independent.

import copy

original = {"name": "Alice", "scores": [90, 85, 78]}

# Shallow copy — copies the outer object, inner objects are shared
shallow = copy.copy(original)
shallow["name"] = "Bob"       # Does NOT affect original
shallow["scores"].append(100) # DOES affect original (same list object!)

print(original["name"])    # Alice (unchanged)
print(original["scores"])  # [90, 85, 78, 100] ← affected!

# Deep copy — recursively copies all nested objects
original = {"name": "Alice", "scores": [90, 85, 78]}
deep = copy.deepcopy(original)
deep["scores"].append(100)

print(original["scores"])  # [90, 85, 78] ← NOT affected
Outer ObjectNested Objects
Assignment (=)Same referenceSame reference
copy.copy() (shallow)New objectSame reference
copy.deepcopy() (deep)New objectNew objects (recursive)
Q9
Explain Python's context manager (the `with` statement).
Intermediate

Ans:

A context manager guarantees cleanup code runs even if an exception occurs. It implements __enter__ and __exit__ dunder methods.

# Without context manager (fragile)
f = open("file.txt", "r")
try:
    data = f.read()
finally:
    f.close()   # Must manually close — could be missed

# With context manager (safe)
with open("file.txt", "r") as f:
    data = f.read()
# f.close() called automatically, even on exception
# Creating your own context manager (class-based)
class DatabaseConnection:
    def __enter__(self):
        self.conn = connect_to_db()
        return self.conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.conn.close()
        return False  # Don't suppress exceptions

with DatabaseConnection() as db:
    db.execute("SELECT 1")
# Creating context managers with @contextmanager (simpler)
from contextlib import contextmanager

@contextmanager
def managed_resource():
    resource = acquire_resource()
    try:
        yield resource          # Code inside 'with' block runs here
    finally:
        release_resource(resource)  # Always runs

with managed_resource() as r:
    r.do_something()
Q10
What is list comprehension and when should you use a generator expression instead?
Basic

Ans:

# List comprehension — creates a full list in memory
squares = [x**2 for x in range(10) if x % 2 == 0]
# [0, 4, 16, 36, 64]

# Dict comprehension
word_lengths = {word: len(word) for word in ["aws", "kubernetes", "docker"]}
# {'aws': 3, 'kubernetes': 10, 'docker': 6}

# Set comprehension
unique_lengths = {len(word) for word in ["cat", "bat", "door", "mat"]}
# {3, 4}

# Generator expression (lazy — use () not [])
squares_gen = (x**2 for x in range(10_000_000))
# No memory allocated yet — computed on demand

# Rule of thumb:
# List comprehension → need all results at once, or reuse multiple times
# Generator expression → single-pass consumption, large/infinite datasets
# Generator expressions compose well (pipeline)
lines = (line.strip() for line in open("huge.log"))
errors = (line for line in lines if "ERROR" in line)
first_10 = list(itertools.islice(errors, 10))
# Memory: only one line loaded at a time
Q11
What is the difference between `__str__` and `__repr__`?
Intermediate

Ans:

MethodPurposeCalled byAudience
__repr__Unambiguous, developer representationrepr(), REPLDevelopers / debugging
__str__Human-readable, user-friendlyprint(), str()End users
from datetime import datetime

dt = datetime.now()
print(repr(dt))  # datetime.datetime(2024, 1, 15, 14, 30, 45, 123456)
print(str(dt))   # 2024-01-15 14:30:45.123456

class ServerConfig:
    def __init__(self, host, port):
        self.host = host
        self.port = port

    def __repr__(self):
        # Should be unambiguous — ideally recreatable
        return f"ServerConfig(host='{self.host}', port={self.port})"

    def __str__(self):
        # Human-friendly
        return f"{self.host}:{self.port}"

cfg = ServerConfig("localhost", 8080)
print(repr(cfg))  # ServerConfig(host='localhost', port=8080)
print(str(cfg))   # localhost:8080
print(cfg)        # localhost:8080  (uses __str__)

Rule: Always define __repr__. Define __str__ only when the user-facing format differs.

Q12
What are Python's `classmethod` and `staticmethod` decorators?
Intermediate

Ans:

class Order:
    TAX_RATE = 0.08

    def __init__(self, amount):
        self.amount = amount

    def total(self):
        """Instance method — has access to self and the instance."""
        return self.amount * (1 + self.TAX_RATE)

    @classmethod
    def from_dict(cls, data: dict):
        """Class method — has access to cls (the class itself, not the instance).
        Useful as alternative constructors."""
        return cls(data["amount"])

    @staticmethod
    def is_valid_amount(amount: float) -> bool:
        """Static method — no access to self or cls.
        A utility function that logically belongs to the class."""
        return amount > 0

# Usage
order = Order(100)
order2 = Order.from_dict({"amount": 200})    # classmethod
print(Order.is_valid_amount(-5))             # False — staticmethod
ReceivesAccess toUse case
Instance methodselfInstance + classMost methods
@classmethodclsClass onlyAlternative constructors
@staticmethodNothingNothingUtility functions
Q13
How does Python's memory management work? What is reference counting?
Advanced

Ans:

Python uses two main mechanisms:

1. Reference Counting — Every object tracks how many references point to it. When the count drops to 0, memory is freed immediately.

import sys

x = [1, 2, 3]
print(sys.getrefcount(x))  # 2 (x + the getrefcount argument)

y = x          # Reference count becomes 3
del x          # Reference count becomes 2
y = None       # Reference count becomes 0 → object freed

2. Cyclic Garbage Collector — Reference counting cannot handle circular references:

# Circular reference — neither object reaches refcount 0
a = {}
b = {}
a["other"] = b
b["other"] = a

# Python's gc module detects and cleans these up
import gc
gc.collect()  # Manually trigger cycle detection

Memory optimization tools:

# __slots__ — prevents __dict__ creation, saves 40-50% memory per instance
class Point:
    __slots__ = ["x", "y"]  # No __dict__, no arbitrary attributes
    def __init__(self, x, y):
        self.x = x
        self.y = y

# For millions of objects, this matters:
import sys
class PointDict:
    def __init__(self, x, y): self.x = x; self.y = y
class PointSlots:
    __slots__ = ["x", "y"]
    def __init__(self, x, y): self.x = x; self.y = y

print(sys.getsizeof(PointDict(1,2)))   # ~48 bytes + dict overhead
print(sys.getsizeof(PointSlots(1,2)))  # ~56 bytes total (no dict)
Q14
What is `asyncio` in Python and when would you use it?
Advanced

Ans:

asyncio is Python’s built-in library for asynchronous I/O using a single-threaded event loop. It uses async/await syntax to write non-blocking code.

import asyncio
import aiohttp   # pip install aiohttp

# WITHOUT asyncio — sequential (slow)
# Each request waits for the previous one to complete
# 10 requests × 200ms each = 2000ms total

# WITH asyncio — concurrent (fast)
# All requests fire "simultaneously" — 10 requests ≈ 200ms total

async def fetch(session, url):
    async with session.get(url) as response:
        return await response.json()

async def fetch_all(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)  # Run all concurrently
    return results

urls = ["https://api.example.com/user/1",
        "https://api.example.com/user/2",
        "https://api.example.com/user/3"]

results = asyncio.run(fetch_all(urls))
# asyncio vs threading vs multiprocessing
# I/O-bound, many connections  → asyncio (best — low overhead)
# I/O-bound, simple            → threading (easier)
# CPU-bound computation        → multiprocessing (bypasses GIL)

Key concepts:

  • async def → defines a coroutine (doesn’t run until awaited)
  • await → suspends the current coroutine, yields control to event loop
  • asyncio.gather() → runs multiple coroutines concurrently
  • asyncio.run() → entry point — starts the event loop
Q15
What are Python type hints and why should you use them?
Intermediate

Ans:

Type hints are annotations that declare the expected types of variables, function parameters, and return values. They don’t affect runtime but enable static analysis tools.

from typing import Optional, List, Dict, Union, Tuple
from dataclasses import dataclass

# Basic type hints
def greet(name: str, times: int = 1) -> str:
    return f"Hello, {name}! " * times

# Complex types
def process_users(users: List[Dict[str, str]]) -> Dict[str, int]:
    return {u["name"]: len(u["email"]) for u in users}

# Optional (can be None)
def find_user(user_id: int) -> Optional[str]:
    return db.get(user_id)   # Returns str or None

# Python 3.10+ — union with |
def parse_value(val: str | int | None) -> float:
    return float(val) if val is not None else 0.0
# dataclass — combines type hints with automatic __init__, __repr__
@dataclass
class ServiceConfig:
    host: str
    port: int
    timeout: float = 30.0
    tags: List[str] = None

    def __post_init__(self):
        if self.tags is None:
            self.tags = []

cfg = ServiceConfig(host="localhost", port=8080)
print(cfg)  # ServiceConfig(host='localhost', port=8080, timeout=30.0, tags=[])

Benefits:

  • IDEs provide better autocomplete and error detection
  • Tools like mypy catch type errors before runtime
  • Code is self-documenting
Q16
What is the difference between `is` and `==` in Python?
Basic

Ans:

  • == checks value equality (do they hold the same data?)
  • is checks identity equality (are they the exact same object in memory?)
a = [1, 2, 3]
b = [1, 2, 3]
c = a

print(a == b)  # True  — same values
print(a is b)  # False — different objects
print(a is c)  # True  — same object (c points to a)
# Integer caching — CPython caches small integers (-5 to 256)
x = 256
y = 256
print(x is y)  # True  — same cached object

x = 257
y = 257
print(x is y)  # False — outside cache range, different objects

# Correct usage of `is`
if response is None:    # ✅ Correct — None is a singleton
    handle_empty()

if response == None:    # ⚠️ Works but less Pythonic
    handle_empty()

Rule: Use is only for None, True, and False (singletons). Use == for everything else.

Q17
What is exception handling in Python? Explain try/except/else/finally.
Basic

Ans:

import logging

def read_config(path: str) -> dict:
    try:
        # Code that may raise an exception
        with open(path) as f:
            return json.load(f)

    except FileNotFoundError:
        # Specific exception — file doesn't exist
        logging.error(f"Config file not found: {path}")
        return {}

    except json.JSONDecodeError as e:
        # Another specific exception — bad JSON
        logging.error(f"Invalid JSON in {path}: {e}")
        raise ValueError(f"Config file is malformed: {path}") from e

    except (PermissionError, OSError) as e:
        # Multiple exception types in one clause
        raise RuntimeError(f"Cannot read config: {e}") from e

    else:
        # Runs ONLY if no exception was raised in try
        logging.info(f"Config loaded successfully from {path}")

    finally:
        # ALWAYS runs — cleanup code (even if exception propagates)
        logging.debug("read_config complete")
# Custom exceptions
class InsufficientFundsError(ValueError):
    def __init__(self, balance: float, amount: float):
        self.balance = balance
        self.amount = amount
        super().__init__(f"Cannot withdraw {amount}: balance is {balance}")

def withdraw(balance: float, amount: float) -> float:
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount
Q18
What are Python's OOP pillars? Explain inheritance, encapsulation, polymorphism, and abstraction.
Intermediate

Ans:

from abc import ABC, abstractmethod

# ABSTRACTION — abstract base class defines interface, not implementation
class NotificationService(ABC):
    @abstractmethod
    def send(self, message: str, recipient: str) -> bool:
        """Must be implemented by subclasses."""
        ...

    def send_with_retry(self, message, recipient, max_retries=3):
        """Concrete method available to all subclasses."""
        for attempt in range(max_retries):
            if self.send(message, recipient):
                return True
        return False

# INHERITANCE — subclass extends/overrides base class
class EmailService(NotificationService):
    def __init__(self, smtp_host: str):
        self._smtp_host = smtp_host   # ENCAPSULATION — _ prefix = private by convention

    def send(self, message: str, recipient: str) -> bool:
        # Concrete implementation
        print(f"Email to {recipient}: {message}")
        return True

class SlackService(NotificationService):
    def __init__(self, webhook_url: str):
        self.__webhook = webhook_url   # __ name mangling = stronger encapsulation

    def send(self, message: str, recipient: str) -> bool:
        print(f"Slack to #{recipient}: {message}")
        return True

# POLYMORPHISM — same interface, different behaviour
services: list[NotificationService] = [
    EmailService("smtp.gmail.com"),
    SlackService("https://hooks.slack.com/..."),
]

alert = "Deployment complete!"
for service in services:
    service.send(alert, "devops-team")  # Calls the right implementation automatically
Q19
What is a Python lambda function and when should you use it?
Basic

Ans:

A lambda is an anonymous single-expression function. It’s syntactic sugar for a simple function.

# Regular function
def double(x):
    return x * 2

# Equivalent lambda
double = lambda x: x * 2

# Lambdas shine as inline callbacks
numbers = [5, 2, 8, 1, 9, 3]
numbers.sort(key=lambda x: -x)   # Sort descending
# [9, 8, 5, 3, 2, 1]

users = [{"name": "Charlie", "age": 30}, {"name": "Alice", "age": 25}]
users.sort(key=lambda u: u["name"])   # Sort by name

# With filter() and map()
evens = list(filter(lambda x: x % 2 == 0, range(10)))  # [0, 2, 4, 6, 8]
squares = list(map(lambda x: x**2, [1, 2, 3, 4]))       # [1, 4, 9, 16]

When NOT to use lambdas:

  • When the logic is complex (use a named function for readability)
  • When you need docstrings or type hints
  • When you’ll reuse the function (assign to a variable instead)
Q20
What is the difference between `@staticmethod` property and `@property` in Python?
Intermediate

Ans:

@property turns a method into a read-only attribute with getter/setter/deleter control.

class Temperature:
    def __init__(self, celsius: float):
        self._celsius = celsius   # Internal storage

    @property
    def celsius(self) -> float:
        """Getter."""
        return self._celsius

    @celsius.setter
    def celsius(self, value: float):
        """Setter — validates before storing."""
        if value < -273.15:
            raise ValueError(f"Temperature below absolute zero: {value}")
        self._celsius = value

    @celsius.deleter
    def celsius(self):
        del self._celsius

    @property
    def fahrenheit(self) -> float:
        """Computed property — no setter needed."""
        return self._celsius * 9/5 + 32

t = Temperature(100)
print(t.fahrenheit)   # 212.0 — looks like an attribute, runs code
t.celsius = -274      # Raises ValueError
t.celsius = 0
print(t.fahrenheit)   # 32.0
Q21
What are Python virtual environments and why are they important?
Basic

Ans:

A virtual environment is an isolated Python environment with its own interpreter and packages — separate from the system Python.

# Create virtual environment
python -m venv venv

# Activate (Linux/macOS)
source venv/bin/activate

# Activate (Windows)
venv\Scripts\activate

# Install packages (only in this venv, not globally)
pip install fastapi uvicorn

# Freeze dependencies
pip freeze > requirements.txt

# Deactivate
deactivate

Why they matter:

  • Dependency isolation — Project A needs Django 3.x, Project B needs Django 4.x — both work on the same machine
  • Reproducibility — requirements.txt + venv ensures everyone runs the same versions
  • No root access required — install packages without sudo
# Modern tooling
# uv — ultra-fast alternative to pip + venv (recommended)
pip install uv
uv venv
uv pip install fastapi

# poetry — dependency management + virtual envs
poetry init
poetry add fastapi
poetry shell
Q22
What is the `__init__.py` file and what is a Python package?
Basic

Ans:

A package is a directory containing Python modules and an __init__.py file. The __init__.py marks the directory as a package and can initialize shared state.

myapp/
├── __init__.py          # Makes myapp a package
├── config.py
├── models/
│   ├── __init__.py      # Makes models a sub-package
│   ├── user.py
│   └── order.py
└── services/
    ├── __init__.py
    └── payment.py
# myapp/__init__.py — expose public API
from .config import settings
from .models.user import User

__version__ = "1.0.0"
__all__ = ["User", "settings"]  # Controls what 'from myapp import *' exports

# Usage
from myapp import User           # Works because of __init__.py
from myapp.models.order import Order  # Direct import also works

In Python 3.3+, __init__.py is optional for “namespace packages”, but best practice is to include it for regular packages to be explicit.

Q23
How do you handle file I/O in Python? What is the difference between text mode and binary mode?
Basic

Ans:

# Reading a file
with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()            # Entire file as string
    # OR
    lines = f.readlines()         # List of lines
    # OR (memory efficient for large files)
    for line in f:                # Iterator — one line at a time
        process(line.strip())

# Writing a file
with open("output.txt", "w", encoding="utf-8") as f:
    f.write("Hello, World!\n")
    f.writelines(["line1\n", "line2\n"])

# Appending
with open("log.txt", "a") as f:
    f.write("New log entry\n")

# Binary mode — for images, PDFs, executables
with open("image.png", "rb") as f:
    data = f.read()   # bytes, not str

with open("copy.png", "wb") as f:
    f.write(data)
ModeDescription
"r"Read text (default)
"w"Write text (overwrites)
"a"Append text
"rb"Read binary
"wb"Write binary
"r+"Read and write
Q24
What is the difference between `multiprocessing` and `threading` in Python?
Advanced

Ans:

import threading
import multiprocessing
import time

# I/O-bound task — threading is fine (GIL released during I/O)
def download_file(url):
    import requests
    return requests.get(url).content

with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
    results = list(pool.map(download_file, urls))

# CPU-bound task — multiprocessing bypasses GIL
def compute_intensive(n):
    return sum(i**2 for i in range(n))

with multiprocessing.Pool(processes=4) as pool:
    results = pool.map(compute_intensive, [10**7] * 8)
Featurethreadingmultiprocessing
ParallelismConcurrent (GIL limits true parallel)True parallel (separate processes)
MemoryShared memorySeparate memory space
CommunicationShared objects (with locks)Queues, Pipes, shared memory
OverheadLowHigher (process startup)
Best forI/O-boundCPU-bound
GIL affectedYesNo
# ProcessPoolExecutor — modern way to use multiprocessing
from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(compute_intensive, [10**7] * 8))
Q25
What are Python's built-in data structures for counting and ordering? Explain `Counter`, `defaultdict`, and `OrderedDict`.
Intermediate

Ans:

from collections import Counter, defaultdict, OrderedDict, deque

# Counter — count occurrences
words = ["aws", "docker", "aws", "kubernetes", "docker", "aws"]
count = Counter(words)
print(count)                    # Counter({'aws': 3, 'docker': 2, 'kubernetes': 1})
print(count.most_common(2))     # [('aws', 3), ('docker', 2)]
count.update(["aws", "helm"])   # Add more counts

# defaultdict — dict with default value for missing keys (no KeyError)
graph = defaultdict(list)
graph["A"].append("B")   # No need to check if "A" exists
graph["A"].append("C")
print(graph)  # defaultdict(<class 'list'>, {'A': ['B', 'C']})

# Versus regular dict:
try:
    regular = {}
    regular["A"].append("B")  # KeyError!
except KeyError:
    regular["A"] = ["B"]      # Must manually initialize

# deque — double-ended queue with O(1) append/pop from both ends
queue = deque(maxlen=5)      # Fixed-size sliding window
for i in range(10):
    queue.append(i)          # Auto-evicts oldest when full
print(queue)                 # deque([5, 6, 7, 8, 9], maxlen=5)

queue.appendleft(99)         # Add to left
queue.rotate(2)              # Rotate elements

Python for AWS & DevOps - Interview Questions & Answers

Q1
What is boto3 and how do you authenticate with AWS using it?
Basic

Ans:

boto3 is the official AWS SDK for Python. It lets you interact with AWS services programmatically — creating EC2 instances, reading S3 objects, invoking Lambdas, and more.

Authentication methods (in order of precedence):

import boto3

# 1. IAM Role (recommended in AWS environments — EC2, Lambda, ECS)
# No credentials needed — SDK picks them up automatically from instance metadata
s3 = boto3.client("s3")

# 2. Environment variables
# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN (optional)
import os
os.environ["AWS_ACCESS_KEY_ID"] = "AKIA..."
os.environ["AWS_SECRET_ACCESS_KEY"] = "..."

# 3. AWS credentials file (~/.aws/credentials)
# [default]
# aws_access_key_id = AKIA...
# aws_secret_access_key = ...

# 4. Explicit credentials (avoid hardcoding — use Secrets Manager instead)
client = boto3.client(
    "s3",
    region_name="us-east-1",
    aws_access_key_id="AKIA...",
    aws_secret_access_key="...",
)

# 5. Named profile
session = boto3.Session(profile_name="dev-account")
s3 = session.client("s3")

Best practice for DevOps:

  • Local development → named profiles (~/.aws/credentials)
  • CI/CD → environment variables injected by the pipeline (GitHub Actions OIDC, GitLab CI vars)
  • AWS workloads → IAM roles attached to EC2 / Lambda / ECS task — never hardcode keys
Q2
What is the difference between a boto3 client and a resource?
Basic

Ans:

clientresource
LevelLow-levelHigh-level (OOP)
ReturnsRaw dicts (JSON-like)Python objects with methods
CoverageAll servicesOnly a few (S3, EC2, DynamoDB, IAM, SQS, SNS)
PaginationManual (paginators)Automatic for some calls
Use whenFull control / all servicesConvenience for supported services
import boto3

# client — low level, returns dicts
client = boto3.client("s3")
response = client.list_buckets()
buckets = response["Buckets"]          # List of dicts
for b in buckets:
    print(b["Name"], b["CreationDate"])

# resource — high level, returns objects
s3 = boto3.resource("s3")
for bucket in s3.buckets.all():        # Bucket objects
    print(bucket.name, bucket.creation_date)

# Mixing both — get a resource's underlying client
bucket = s3.Bucket("my-bucket")
low_level_client = bucket.meta.client

In modern code, client is preferred for its complete API coverage and predictable behavior. Resources are being soft-deprecated by AWS.

Q3
How do you upload, download, and list objects in S3 using boto3?
Basic

Ans:

import boto3
from pathlib import Path

s3 = boto3.client("s3", region_name="us-east-1")
BUCKET = "my-company-artifacts"

# --- Upload ---
# upload_file — reads from disk (streams, handles large files)
s3.upload_file(
    Filename="build/app.zip",
    Bucket=BUCKET,
    Key="releases/v1.2.0/app.zip",
    ExtraArgs={"ServerSideEncryption": "AES256"},
)

# put_object — upload bytes/strings directly (small objects)
s3.put_object(
    Bucket=BUCKET,
    Key="config/app.json",
    Body=b'{"env": "prod"}',
    ContentType="application/json",
)

# --- Download ---
s3.download_file(BUCKET, "releases/v1.2.0/app.zip", "/tmp/app.zip")

# get_object — read content into memory
response = s3.get_object(Bucket=BUCKET, Key="config/app.json")
content = response["Body"].read().decode("utf-8")

# --- List objects (paginated) ---
paginator = s3.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=BUCKET, Prefix="releases/")

for page in pages:
    for obj in page.get("Contents", []):
        print(obj["Key"], obj["Size"], obj["LastModified"])

# --- Delete ---
s3.delete_object(Bucket=BUCKET, Key="releases/v1.0.0/app.zip")

# Delete multiple objects at once (batch)
s3.delete_objects(
    Bucket=BUCKET,
    Delete={"Objects": [{"Key": "old/file1.txt"}, {"Key": "old/file2.txt"}]},
)
Q4
How do you write a Python AWS Lambda function? What are the handler signature and context object?
Basic

Ans:

import json
import logging
import os
import boto3

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event: dict, context) -> dict:
    """
    event   — trigger payload (dict shape varies by trigger source)
    context — runtime info provided by Lambda
    """
    # --- context attributes ---
    logger.info("Function name:    %s", context.function_name)
    logger.info("Request ID:       %s", context.aws_request_id)
    logger.info("Memory (MB):      %s", context.memory_limit_in_mb)
    logger.info("Time remaining:   %s ms", context.get_remaining_time_in_millis())

    # --- read environment variables ---
    table_name = os.environ["DYNAMODB_TABLE"]

    # --- business logic ---
    try:
        body = json.loads(event.get("body", "{}"))
        user_id = body["user_id"]

        dynamodb = boto3.resource("dynamodb")
        table = dynamodb.Table(table_name)
        table.put_item(Item={"user_id": user_id, "status": "active"})

        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({"message": "User created", "user_id": user_id}),
        }

    except KeyError as e:
        return {
            "statusCode": 400,
            "body": json.dumps({"error": f"Missing field: {e}"}),
        }
    except Exception as e:
        logger.exception("Unexpected error")
        return {"statusCode": 500, "body": json.dumps({"error": "Internal error"})}

Key rules:

  • The handler must be def lambda_handler(event, context) (name configurable)
  • Return a dict with statusCode and body for API Gateway integration
  • Use logger (not print) — logs go to CloudWatch automatically
  • Keep the handler thin; put business logic in separate modules
Q5
How do you manage AWS EC2 instances with Python boto3?
Intermediate

Ans:

import boto3
import time

ec2 = boto3.client("ec2", region_name="us-east-1")

# --- Launch an instance ---
response = ec2.run_instances(
    ImageId="ami-0c55b159cbfafe1f0",   # Amazon Linux 2
    InstanceType="t3.micro",
    MinCount=1,
    MaxCount=1,
    KeyName="my-key-pair",
    SecurityGroupIds=["sg-0123456789abcdef0"],
    SubnetId="subnet-0123456789abcdef0",
    TagSpecifications=[{
        "ResourceType": "instance",
        "Tags": [
            {"Key": "Name", "Value": "web-server-01"},
            {"Key": "Environment", "Value": "prod"},
        ],
    }],
    UserData="""#!/bin/bash
yum update -y
yum install -y nginx
systemctl start nginx
""",
)
instance_id = response["Instances"][0]["InstanceId"]
print(f"Launched: {instance_id}")

# --- Wait until running ---
waiter = ec2.get_waiter("instance_running")
waiter.wait(InstanceIds=[instance_id])
print("Instance is running")

# --- Describe / filter instances ---
response = ec2.describe_instances(
    Filters=[
        {"Name": "tag:Environment", "Values": ["prod"]},
        {"Name": "instance-state-name", "Values": ["running"]},
    ]
)

for reservation in response["Reservations"]:
    for inst in reservation["Instances"]:
        print(inst["InstanceId"], inst["PrivateIpAddress"])

# --- Stop / Start / Terminate ---
ec2.stop_instances(InstanceIds=[instance_id])
ec2.start_instances(InstanceIds=[instance_id])
ec2.terminate_instances(InstanceIds=[instance_id])
Q6
How do you read AWS Secrets Manager and Parameter Store values in Python?
Intermediate

Ans:

import boto3
import json
import os

# --- Secrets Manager ---
def get_secret(secret_name: str, region: str = "us-east-1") -> dict:
    client = boto3.client("secretsmanager", region_name=region)
    response = client.get_secret_value(SecretId=secret_name)

    # Secret can be a JSON string or a plain string
    if "SecretString" in response:
        return json.loads(response["SecretString"])
    else:
        # Binary secret
        return response["SecretBinary"]

# Usage — never hardcode DB credentials
db_creds = get_secret("prod/myapp/postgres")
conn_str = f"postgresql://{db_creds['username']}:{db_creds['password']}@{db_creds['host']}/mydb"


# --- SSM Parameter Store ---
ssm = boto3.client("ssm", region_name="us-east-1")

# Single parameter (SecureString is decrypted automatically with WithDecryption=True)
response = ssm.get_parameter(
    Name="/myapp/prod/api_key",
    WithDecryption=True,
)
api_key = response["Parameter"]["Value"]

# Fetch all parameters for an environment at once
response = ssm.get_parameters_by_path(
    Path="/myapp/prod/",
    Recursive=True,
    WithDecryption=True,
)
config = {p["Name"].split("/")[-1]: p["Value"] for p in response["Parameters"]}
# {"api_key": "...", "db_host": "...", "feature_flag": "true"}


# --- Best practice pattern — cache at Lambda cold start ---
_cache: dict = {}

def get_param(name: str) -> str:
    if name not in _cache:
        ssm = boto3.client("ssm")
        _cache[name] = ssm.get_parameter(Name=name, WithDecryption=True)["Parameter"]["Value"]
    return _cache[name]
Q7
How do you use Python's `subprocess` module for shell automation in DevOps scripts?
Intermediate

Ans:

import subprocess
import shlex

# --- Basic run (recommended — raises on failure) ---
result = subprocess.run(
    ["git", "pull", "origin", "main"],
    capture_output=True,   # Capture stdout and stderr
    text=True,             # Decode bytes to str
    check=True,            # Raise CalledProcessError if exit code != 0
)
print(result.stdout)

# --- Capture output and check return code manually ---
result = subprocess.run(
    ["docker", "ps", "--format", "{{.Names}}"],
    capture_output=True,
    text=True,
)
if result.returncode == 0:
    containers = result.stdout.strip().splitlines()
else:
    print("Error:", result.stderr)

# --- Run shell commands (use shlex.split to avoid injection) ---
cmd = "kubectl get pods -n production -o json"
result = subprocess.run(shlex.split(cmd), capture_output=True, text=True, check=True)

# --- Piping commands ---
ps = subprocess.Popen(["ps", "aux"], stdout=subprocess.PIPE)
grep = subprocess.Popen(
    ["grep", "nginx"],
    stdin=ps.stdout,
    stdout=subprocess.PIPE,
    text=True,
)
ps.stdout.close()
output, _ = grep.communicate()

# --- Timeout (prevent hanging in CI) ---
try:
    subprocess.run(["make", "test"], timeout=300, check=True)
except subprocess.TimeoutExpired:
    print("Tests timed out after 5 minutes")
except subprocess.CalledProcessError as e:
    print(f"Tests failed with exit code {e.returncode}")
    print(e.stderr)

Security warning: Never use shell=True with user-supplied input — it enables shell injection. Always pass commands as a list.

Q8
How do you parse and manipulate YAML and JSON configuration files in Python?
Basic

Ans:

import json
import yaml      # pip install pyyaml
from pathlib import Path

# --- JSON ---
# Read
with open("config.json") as f:
    config = json.load(f)

# Write (pretty-printed)
with open("output.json", "w") as f:
    json.dump(config, f, indent=2, default=str)  # default=str handles datetime etc.

# Parse string
data = json.loads('{"env": "prod", "replicas": 3}')

# --- YAML ---
# Read single document
with open("deployment.yaml") as f:
    manifest = yaml.safe_load(f)   # Always use safe_load, never yaml.load

# Read multiple documents (Kubernetes multi-resource files)
with open("k8s-resources.yaml") as f:
    docs = list(yaml.safe_load_all(f))

# Write
with open("output.yaml", "w") as f:
    yaml.dump(manifest, f, default_flow_style=False, sort_keys=False)

# --- Practical: patch a Kubernetes manifest ---
with open("deployment.yaml") as f:
    deploy = yaml.safe_load(f)

# Update image tag from CI pipeline
image_tag = "v1.2.3"
deploy["spec"]["template"]["spec"]["containers"][0]["image"] = f"myapp:{image_tag}"
deploy["spec"]["replicas"] = 3

with open("deployment.yaml", "w") as f:
    yaml.dump(deploy, f, default_flow_style=False)

# --- Practical: generate Terraform variable files from Python dicts ---
tf_vars = {"region": "us-east-1", "instance_type": "t3.medium", "min_capacity": 2}
with open("terraform.tfvars.json", "w") as f:
    json.dump(tf_vars, f, indent=2)
Q9
How do you use Python's `logging` module effectively in production/DevOps scripts?
Intermediate

Ans:

import logging
import json
import sys
from datetime import datetime

# --- Basic structured logging (JSON format for log aggregators) ---
class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        log_data = {
            "timestamp": datetime.utcnow().isoformat() + "Z",
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "module": record.module,
            "line": record.lineno,
        }
        if record.exc_info:
            log_data["exception"] = self.formatException(record.exc_info)
        return json.dumps(log_data)


def get_logger(name: str) -> logging.Logger:
    logger = logging.getLogger(name)
    if not logger.handlers:
        handler = logging.StreamHandler(sys.stdout)
        handler.setFormatter(JsonFormatter())
        logger.addHandler(handler)
        logger.setLevel(logging.INFO)
        logger.propagate = False
    return logger


# --- Usage ---
logger = get_logger(__name__)

logger.info("Deployment started", extra={"version": "v1.2.3"})
logger.warning("High CPU detected", extra={"instance": "i-0abc123", "cpu_pct": 87})

try:
    result = deploy_service()
except Exception:
    logger.exception("Deployment failed")   # Logs traceback automatically
    sys.exit(1)

# --- Lambda-specific: logging level from env var ---
import os
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.getLogger().setLevel(getattr(logging, LOG_LEVEL))

# --- Log levels guideline for DevOps scripts ---
# DEBUG   → detailed tracing (disabled in prod)
# INFO    → normal operations (deployment steps, resource counts)
# WARNING → recoverable issue (retrying, degraded mode)
# ERROR   → operation failed but script continues
# CRITICAL → script must abort
Q10
How do you use `argparse` to build a reusable DevOps CLI tool in Python?
Intermediate

Ans:

#!/usr/bin/env python3
"""
deploy.py — deployment automation CLI
Usage:
  python deploy.py deploy --env prod --version v1.2.3
  python deploy.py rollback --env staging --steps 1
  python deploy.py status --env prod
"""

import argparse
import sys


def deploy(args):
    print(f"Deploying version {args.version} to {args.env}")
    if args.dry_run:
        print("[DRY RUN] No changes made")


def rollback(args):
    print(f"Rolling back {args.steps} step(s) in {args.env}")


def status(args):
    print(f"Checking status in {args.env}")


def main():
    parser = argparse.ArgumentParser(
        description="Deployment automation tool",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("--dry-run", action="store_true", help="Preview without making changes")

    subparsers = parser.add_subparsers(dest="command", required=True)

    # deploy subcommand
    deploy_parser = subparsers.add_parser("deploy", help="Deploy a version")
    deploy_parser.add_argument("--env", required=True, choices=["dev", "staging", "prod"])
    deploy_parser.add_argument("--version", required=True, help="Version tag, e.g. v1.2.3")
    deploy_parser.set_defaults(func=deploy)

    # rollback subcommand
    rollback_parser = subparsers.add_parser("rollback", help="Roll back deployments")
    rollback_parser.add_argument("--env", required=True, choices=["dev", "staging", "prod"])
    rollback_parser.add_argument("--steps", type=int, default=1, help="Number of versions to roll back")
    rollback_parser.set_defaults(func=rollback)

    # status subcommand
    status_parser = subparsers.add_parser("status", help="Show deployment status")
    status_parser.add_argument("--env", required=True)
    status_parser.set_defaults(func=status)

    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()

Why argparse over raw sys.argv:

  • Auto-generates --help
  • Type validation and choices enforcement
  • Subcommands for multi-action tools
  • Cleaner than sys.argv[1] indexing
Q11
How do you use Python to interact with Docker — the Docker SDK?
Intermediate

Ans:

import docker   # pip install docker

client = docker.from_env()  # Uses DOCKER_HOST or local socket

# --- Build an image ---
image, build_logs = client.images.build(
    path=".",
    tag="myapp:v1.2.3",
    rm=True,            # Remove intermediate containers
    buildargs={"APP_ENV": "production"},
)
for log in build_logs:
    if "stream" in log:
        print(log["stream"], end="")

# --- Run a container ---
container = client.containers.run(
    "myapp:v1.2.3",
    name="myapp-test",
    environment={"DATABASE_URL": "postgresql://localhost/test"},
    ports={"8080/tcp": 8080},
    detach=True,       # Non-blocking
    remove=True,       # Auto-remove when stopped
)

# Wait for it to finish
result = container.wait()
print("Exit code:", result["StatusCode"])
print(container.logs().decode())

# --- One-shot execution (blocking) ---
output = client.containers.run(
    "alpine:3.18",
    command="echo 'hello from container'",
    remove=True,
)
print(output.decode())

# --- List running containers ---
for c in client.containers.list():
    print(c.name, c.status, c.image.tags)

# --- Push to ECR ---
import base64, boto3

ecr = boto3.client("ecr", region_name="us-east-1")
token = ecr.get_authorization_token()["authorizationData"][0]
username, password = base64.b64decode(token["authorizationToken"]).decode().split(":")
registry = token["proxyEndpoint"]

client.images.push(
    f"{registry}/myapp",
    tag="v1.2.3",
    auth_config={"username": username, "password": password},
)
Q12
How do you use the Kubernetes Python client to manage cluster resources?
Intermediate

Ans:

from kubernetes import client, config, watch   # pip install kubernetes

# Load kubeconfig — works locally and in-cluster
try:
    config.load_incluster_config()   # Running inside a pod
except config.ConfigException:
    config.load_kube_config()        # Local ~/.kube/config

v1 = client.CoreV1Api()
apps_v1 = client.AppsV1Api()

# --- List pods in a namespace ---
pods = v1.list_namespaced_pod(namespace="production")
for pod in pods.items:
    print(pod.metadata.name, pod.status.phase)

# --- Scale a deployment ---
apps_v1.patch_namespaced_deployment_scale(
    name="myapp",
    namespace="production",
    body={"spec": {"replicas": 5}},
)

# --- Create a ConfigMap ---
cm = client.V1ConfigMap(
    metadata=client.V1ObjectMeta(name="app-config", namespace="production"),
    data={"LOG_LEVEL": "INFO", "FEATURE_FLAG": "true"},
)
v1.create_namespaced_config_map(namespace="production", body=cm)

# --- Apply a manifest from a YAML file ---
import yaml
from kubernetes.utils import create_from_yaml

with open("deployment.yaml") as f:
    manifest = yaml.safe_load(f)

k8s_client = client.ApiClient()
create_from_yaml(k8s_client, yaml_objects=[manifest], namespace="production")

# --- Watch pod events (streaming) ---
w = watch.Watch()
for event in w.stream(v1.list_namespaced_pod, namespace="production", timeout_seconds=60):
    print(f"{event['type']}: {event['object'].metadata.name}")
    if event["object"].status.phase == "Running":
        w.stop()
Q13
How do you send alerts and notifications from a Python script in a DevOps pipeline?
Intermediate

Ans:

import requests
import json
import smtplib
import boto3
from email.mime.text import MIMEText

# --- Slack webhook ---
def send_slack_alert(message: str, webhook_url: str, channel: str = "#devops-alerts"):
    payload = {
        "channel": channel,
        "text": message,
        "attachments": [{
            "color": "danger",
            "fields": [{"title": "Environment", "value": "production", "short": True}],
        }],
    }
    resp = requests.post(webhook_url, json=payload, timeout=10)
    resp.raise_for_status()

# --- AWS SNS (for PagerDuty/email/SMS fan-out) ---
def send_sns_alert(message: str, subject: str, topic_arn: str):
    sns = boto3.client("sns", region_name="us-east-1")
    sns.publish(
        TopicArn=topic_arn,
        Message=message,
        Subject=subject,
    )

# --- AWS SES email ---
def send_email(to: str, subject: str, body: str, sender: str = "[email protected]"):
    ses = boto3.client("ses", region_name="us-east-1")
    ses.send_email(
        Source=sender,
        Destination={"ToAddresses": [to]},
        Message={
            "Subject": {"Data": subject},
            "Body": {"Text": {"Data": body}},
        },
    )

# --- Practical: wrap a deployment with alerts ---
def deploy_with_alerting(version: str):
    try:
        run_deployment(version)
        send_slack_alert(f":white_check_mark: Deployed {version} successfully")
        send_sns_alert(
            f"Deployment of {version} succeeded",
            subject="Deploy Success",
            topic_arn="arn:aws:sns:us-east-1:123456789012:deploy-notifications",
        )
    except Exception as e:
        send_slack_alert(f":red_circle: Deployment of {version} FAILED: {e}")
        raise
Q14
How do you use Python to query and publish metrics to AWS CloudWatch?
Intermediate

Ans:

import boto3
from datetime import datetime, timedelta, timezone

cw = boto3.client("cloudwatch", region_name="us-east-1")

# --- Publish custom metrics ---
cw.put_metric_data(
    Namespace="MyApp/Production",
    MetricData=[
        {
            "MetricName": "DeploymentDuration",
            "Dimensions": [
                {"Name": "Environment", "Value": "prod"},
                {"Name": "Service", "Value": "api"},
            ],
            "Value": 45.3,           # seconds
            "Unit": "Seconds",
            "Timestamp": datetime.now(timezone.utc),
        },
        {
            "MetricName": "ActiveUsers",
            "Value": 1250,
            "Unit": "Count",
        },
    ],
)

# --- Query existing metrics ---
now = datetime.now(timezone.utc)
response = cw.get_metric_statistics(
    Namespace="AWS/EC2",
    MetricName="CPUUtilization",
    Dimensions=[{"Name": "InstanceId", "Value": "i-0abc123def456"}],
    StartTime=now - timedelta(hours=1),
    EndTime=now,
    Period=300,        # 5-minute granularity
    Statistics=["Average", "Maximum"],
)
for dp in sorted(response["Datapoints"], key=lambda x: x["Timestamp"]):
    print(f"{dp['Timestamp']}: avg={dp['Average']:.1f}%, max={dp['Maximum']:.1f}%")

# --- Create a CloudWatch alarm from Python ---
cw.put_metric_alarm(
    AlarmName="HighCPU-api-server",
    MetricName="CPUUtilization",
    Namespace="AWS/EC2",
    Statistic="Average",
    Period=300,
    EvaluationPeriods=2,
    Threshold=80.0,
    ComparisonOperator="GreaterThanThreshold",
    Dimensions=[{"Name": "InstanceId", "Value": "i-0abc123def456"}],
    AlarmActions=["arn:aws:sns:us-east-1:123456789012:ops-alerts"],
    TreatMissingData="notBreaching",
)
Q15
How do you handle pagination in boto3 API calls?
Intermediate

Ans:

Many AWS list/describe APIs return a maximum number of results with a continuation token. Forgetting to paginate is a common bug — you silently miss resources.

import boto3

ec2 = boto3.client("ec2", region_name="us-east-1")
s3  = boto3.client("s3")

# --- Method 1: Manual pagination (fragile, avoid) ---
all_instances = []
response = ec2.describe_instances(MaxResults=100)
while True:
    for r in response["Reservations"]:
        all_instances.extend(r["Instances"])
    next_token = response.get("NextToken")
    if not next_token:
        break
    response = ec2.describe_instances(MaxResults=100, NextToken=next_token)

# --- Method 2: boto3 Paginator (recommended) ---
paginator = ec2.get_paginator("describe_instances")
pages = paginator.paginate(
    Filters=[{"Name": "instance-state-name", "Values": ["running"]}]
)
all_instances = [
    inst
    for page in pages
    for r in page["Reservations"]
    for inst in r["Instances"]
]

# --- S3 list objects ---
s3_paginator = s3.get_paginator("list_objects_v2")
all_keys = []
for page in s3_paginator.paginate(Bucket="my-bucket", Prefix="logs/2024/"):
    for obj in page.get("Contents", []):
        all_keys.append(obj["Key"])

print(f"Found {len(all_keys)} objects")

# --- Paginate with page size limit ---
for page in s3_paginator.paginate(
    Bucket="my-bucket",
    PaginationConfig={"MaxItems": 500, "PageSize": 100},
):
    for obj in page.get("Contents", []):
        process(obj)

Available paginators:

# See all paginators for a service
ec2.meta.service_model.operation_names
client.can_paginate("describe_instances")  # True if paginator exists
Q16
How do you run parallel AWS API calls using Python's `concurrent.futures`?
Advanced

Ans:

import boto3
import concurrent.futures
from typing import List, Dict

ec2 = boto3.client("ec2", region_name="us-east-1")

REGIONS = ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"]

# --- Sequential (slow — waits for each region) ---
def get_instances_sequential() -> List[Dict]:
    results = []
    for region in REGIONS:
        client = boto3.client("ec2", region_name=region)
        paginator = client.get_paginator("describe_instances")
        for page in paginator.paginate():
            for r in page["Reservations"]:
                for inst in r["Instances"]:
                    results.append({**inst, "Region": region})
    return results

# --- Parallel (fast — all regions queried simultaneously) ---
def get_instances_in_region(region: str) -> List[Dict]:
    client = boto3.client("ec2", region_name=region)
    paginator = client.get_paginator("describe_instances")
    instances = []
    for page in paginator.paginate():
        for r in page["Reservations"]:
            for inst in r["Instances"]:
                instances.append({**inst, "Region": region})
    return instances

def get_all_instances_parallel() -> List[Dict]:
    results = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=len(REGIONS)) as executor:
        futures = {executor.submit(get_instances_in_region, r): r for r in REGIONS}
        for future in concurrent.futures.as_completed(futures):
            region = futures[future]
            try:
                results.extend(future.result())
            except Exception as e:
                print(f"Region {region} failed: {e}")
    return results

# --- Parallel S3 uploads ---
def upload_file(args):
    bucket, key, local_path = args
    boto3.client("s3").upload_file(local_path, bucket, key)
    return key

files = [("my-bucket", f"logs/{f}", f"/tmp/{f}") for f in ["a.log", "b.log", "c.log"]]

with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
    uploaded = list(pool.map(upload_file, files))
    print(f"Uploaded {len(uploaded)} files")

Note: Use ThreadPoolExecutor (not ProcessPoolExecutor) for AWS API calls — they are I/O-bound and the GIL is released during network I/O.

Q17
How do you write a Python script to clean up old AWS resources automatically?
Advanced

Ans:

#!/usr/bin/env python3
"""
Cleanup script: deletes EBS snapshots older than 30 days not attached to any AMI.
Run as a Lambda on a schedule or manually.
"""

import boto3
import logging
from datetime import datetime, timezone, timedelta

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

MAX_AGE_DAYS = 30


def get_ami_snapshot_ids(ec2) -> set:
    """Return all snapshot IDs used by registered AMIs."""
    snap_ids = set()
    paginator = ec2.get_paginator("describe_images")
    for page in paginator.paginate(Owners=["self"]):
        for image in page["Images"]:
            for bd in image.get("BlockDeviceMappings", []):
                if "Ebs" in bd and "SnapshotId" in bd["Ebs"]:
                    snap_ids.add(bd["Ebs"]["SnapshotId"])
    return snap_ids


def cleanup_old_snapshots(dry_run: bool = True) -> dict:
    ec2 = boto3.client("ec2", region_name="us-east-1")
    cutoff = datetime.now(timezone.utc) - timedelta(days=MAX_AGE_DAYS)

    ami_snap_ids = get_ami_snapshot_ids(ec2)
    deleted, skipped, errors = [], [], []

    paginator = ec2.get_paginator("describe_snapshots")
    for page in paginator.paginate(OwnerIds=["self"]):
        for snap in page["Snapshots"]:
            snap_id = snap["SnapshotId"]
            start_time = snap["StartTime"]

            # Skip recent snapshots
            if start_time > cutoff:
                skipped.append(snap_id)
                continue

            # Skip AMI-linked snapshots
            if snap_id in ami_snap_ids:
                logger.info("Skipping AMI snapshot: %s", snap_id)
                skipped.append(snap_id)
                continue

            if dry_run:
                logger.info("[DRY RUN] Would delete: %s (%s, %d GB)",
                            snap_id, start_time.date(), snap["VolumeSize"])
                deleted.append(snap_id)
            else:
                try:
                    ec2.delete_snapshot(SnapshotId=snap_id)
                    logger.info("Deleted: %s", snap_id)
                    deleted.append(snap_id)
                except ec2.exceptions.ClientError as e:
                    logger.error("Failed to delete %s: %s", snap_id, e)
                    errors.append(snap_id)

    return {"deleted": len(deleted), "skipped": len(skipped), "errors": len(errors)}


def lambda_handler(event, context):
    dry_run = event.get("dry_run", False)
    summary = cleanup_old_snapshots(dry_run=dry_run)
    logger.info("Summary: %s", summary)
    return summary


if __name__ == "__main__":
    import argparse
    p = argparse.ArgumentParser()
    p.add_argument("--dry-run", action="store_true")
    args = p.parse_args()
    print(cleanup_old_snapshots(dry_run=args.dry_run))
Q18
How do you use Python to interact with AWS DynamoDB?
Intermediate

Ans:

import boto3
from boto3.dynamodb.conditions import Key, Attr
from decimal import Decimal

dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
table = dynamodb.Table("deployments")

# --- Put item ---
table.put_item(Item={
    "service_id": "api-gateway",        # Partition key
    "deploy_time": "2024-01-15T14:30Z", # Sort key
    "version": "v1.2.3",
    "status": "success",
    "duration_s": Decimal("45.3"),      # DynamoDB uses Decimal, not float
    "metadata": {"region": "us-east-1", "triggered_by": "CI"},
})

# --- Get item ---
response = table.get_item(
    Key={"service_id": "api-gateway", "deploy_time": "2024-01-15T14:30Z"}
)
item = response.get("Item")

# --- Query (uses index — efficient) ---
response = table.query(
    KeyConditionExpression=Key("service_id").eq("api-gateway"),
    FilterExpression=Attr("status").eq("success"),
    Limit=10,
    ScanIndexForward=False,   # Sort descending by sort key
)
for item in response["Items"]:
    print(item["deploy_time"], item["version"])

# --- Update item ---
table.update_item(
    Key={"service_id": "api-gateway", "deploy_time": "2024-01-15T14:30Z"},
    UpdateExpression="SET #s = :s, retry_count = retry_count + :inc",
    ExpressionAttributeNames={"#s": "status"},   # "status" is reserved word
    ExpressionAttributeValues={":s": "rolled_back", ":inc": 1},
)

# --- Conditional write (optimistic locking) ---
try:
    table.put_item(
        Item={"service_id": "api-gateway", "deploy_time": "2024-01-15T14:30Z", "status": "deploying"},
        ConditionExpression=Attr("service_id").not_exists(),   # Only if doesn't exist
    )
except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
    print("Item already exists — concurrent write prevented")

# --- Batch write ---
with table.batch_writer() as batch:
    for i in range(50):
        batch.put_item(Item={"service_id": f"svc-{i}", "deploy_time": "2024-01-15", "status": "ok"})
Q19
How do you use Python to parse and analyse application log files in a DevOps context?
Intermediate

Ans:

import re
import json
from collections import Counter, defaultdict
from pathlib import Path
from datetime import datetime

# --- Parse structured JSON logs ---
def parse_json_logs(log_file: str) -> list:
    errors = []
    with open(log_file) as f:
        for line_num, line in enumerate(f, 1):
            line = line.strip()
            if not line:
                continue
            try:
                entry = json.loads(line)
                if entry.get("level") in ("ERROR", "CRITICAL"):
                    errors.append(entry)
            except json.JSONDecodeError:
                pass   # Skip malformed lines
    return errors

# --- Parse unstructured logs with regex ---
LOG_PATTERN = re.compile(
    r'(?P<ip>\d+\.\d+\.\d+\.\d+) .* \[(?P<time>[^\]]+)\] '
    r'"(?P<method>\w+) (?P<path>\S+) HTTP/[\d.]+" '
    r'(?P<status>\d+) (?P<size>\d+)'
)

def parse_access_log(log_file: str) -> dict:
    status_counts = Counter()
    slow_requests = []

    with open(log_file) as f:
        for line in f:
            m = LOG_PATTERN.match(line)
            if not m:
                continue
            status_counts[m.group("status")] += 1
            if int(m.group("size")) > 1_000_000:   # Response > 1 MB
                slow_requests.append(m.group("path"))

    return {
        "total": sum(status_counts.values()),
        "5xx_errors": sum(v for k, v in status_counts.items() if k.startswith("5")),
        "status_breakdown": dict(status_counts.most_common()),
        "large_responses": slow_requests[:10],
    }

# --- Download and analyse CloudWatch logs ---
import boto3

def get_cloudwatch_errors(log_group: str, hours: int = 1) -> list:
    logs = boto3.client("logs", region_name="us-east-1")
    end_ms = int(datetime.utcnow().timestamp() * 1000)
    start_ms = end_ms - hours * 3600 * 1000

    response = logs.filter_log_events(
        logGroupName=log_group,
        startTime=start_ms,
        endTime=end_ms,
        filterPattern="ERROR",
    )
    return [e["message"] for e in response["events"]]
Q20
How do you use Python with Terraform — passing variables and parsing outputs?
Advanced

Ans:

import subprocess
import json
import os
from pathlib import Path


def terraform(cmd: list[str], cwd: str, env_vars: dict = None) -> subprocess.CompletedProcess:
    env = {**os.environ, **(env_vars or {})}
    result = subprocess.run(
        ["terraform"] + cmd,
        cwd=cwd,
        capture_output=True,
        text=True,
        env=env,
    )
    if result.returncode != 0:
        raise RuntimeError(f"Terraform failed:\n{result.stderr}")
    return result


def deploy_infrastructure(
    tf_dir: str,
    variables: dict,
    auto_approve: bool = False,
) -> dict:
    # Write variables to a tfvars file
    tfvars_path = Path(tf_dir) / "override.auto.tfvars.json"
    tfvars_path.write_text(json.dumps(variables, indent=2))

    try:
        # Init (idempotent)
        terraform(["init", "-input=false"], cwd=tf_dir)

        # Plan
        terraform(
            ["plan", "-input=false", "-out=tfplan", "-detailed-exitcode"],
            cwd=tf_dir,
        )

        # Apply
        apply_args = ["apply", "-input=false", "tfplan"]
        if auto_approve:
            apply_args.insert(1, "-auto-approve")
        terraform(apply_args, cwd=tf_dir)

        # Read outputs
        result = terraform(["output", "-json"], cwd=tf_dir)
        outputs = json.loads(result.stdout)
        return {k: v["value"] for k, v in outputs.items()}

    finally:
        tfvars_path.unlink(missing_ok=True)   # Clean up


# --- Usage in CI pipeline ---
outputs = deploy_infrastructure(
    tf_dir="infra/environments/prod",
    variables={
        "region": "us-east-1",
        "instance_type": "t3.medium",
        "app_version": os.environ["APP_VERSION"],
    },
    auto_approve=True,
)
print("ALB DNS:", outputs["alb_dns_name"])
print("RDS Endpoint:", outputs["rds_endpoint"])

# Pass Terraform output to Ansible or other tools
with open("inventory.json", "w") as f:
    json.dump({"all": {"hosts": outputs["instance_ips"]}}, f)
Q21
How do you write a health check script in Python for a DevOps monitoring setup?
Intermediate

Ans:

#!/usr/bin/env python3
"""
health_check.py — checks service health and alerts on failure.
"""

import requests
import boto3
import sys
import logging
import time
from dataclasses import dataclass, field
from typing import List

logger = logging.getLogger(__name__)

@dataclass
class HealthCheckResult:
    name: str
    url: str
    healthy: bool
    status_code: int = 0
    response_time_ms: float = 0
    error: str = ""

@dataclass
class HealthCheckConfig:
    name: str
    url: str
    expected_status: int = 200
    timeout_s: int = 5
    expected_text: str = ""


def check_endpoint(cfg: HealthCheckConfig) -> HealthCheckResult:
    start = time.monotonic()
    try:
        resp = requests.get(cfg.url, timeout=cfg.timeout_s)
        elapsed = (time.monotonic() - start) * 1000

        healthy = resp.status_code == cfg.expected_status
        if cfg.expected_text and cfg.expected_text not in resp.text:
            healthy = False

        return HealthCheckResult(
            name=cfg.name,
            url=cfg.url,
            healthy=healthy,
            status_code=resp.status_code,
            response_time_ms=round(elapsed, 1),
        )
    except requests.RequestException as e:
        return HealthCheckResult(name=cfg.name, url=cfg.url, healthy=False, error=str(e))


def run_health_checks(checks: List[HealthCheckConfig]) -> List[HealthCheckResult]:
    results = [check_endpoint(c) for c in checks]
    failed = [r for r in results if not r.healthy]

    for r in results:
        status = "OK" if r.healthy else "FAIL"
        logger.info("[%s] %s — %dms", status, r.name, r.response_time_ms)

    if failed:
        # Publish to CloudWatch
        cw = boto3.client("cloudwatch", region_name="us-east-1")
        cw.put_metric_data(
            Namespace="HealthChecks",
            MetricData=[{
                "MetricName": "UnhealthyEndpoints",
                "Value": len(failed),
                "Unit": "Count",
            }],
        )

    return results


ENDPOINTS = [
    HealthCheckConfig("API Gateway", "https://api.myapp.com/health"),
    HealthCheckConfig("Auth Service", "https://auth.myapp.com/ping", expected_text="pong"),
    HealthCheckConfig("Admin Panel", "https://admin.myapp.com/", expected_status=200),
]

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    results = run_health_checks(ENDPOINTS)
    failed = [r for r in results if not r.healthy]
    sys.exit(1 if failed else 0)
Q22
How does Python's `os` and `pathlib` module help in writing portable DevOps automation scripts?
Basic

Ans:

import os
from pathlib import Path

# --- Environment variables ---
APP_ENV   = os.environ.get("APP_ENV", "dev")
DB_HOST   = os.environ["DB_HOST"]           # Raises KeyError if missing
PORT      = int(os.environ.get("PORT", "8080"))

# --- pathlib — object-oriented paths (preferred over os.path) ---
BASE_DIR  = Path(__file__).parent.parent    # Two levels up from this file
CONFIG    = BASE_DIR / "config" / f"{APP_ENV}.yaml"
LOG_DIR   = Path("/var/log/myapp")

# Create directories
LOG_DIR.mkdir(parents=True, exist_ok=True)

# File operations
if CONFIG.exists():
    content = CONFIG.read_text(encoding="utf-8")
    data = CONFIG.read_bytes()

CONFIG.write_text("key: value\n")

# Iterate over files matching a pattern
for log_file in LOG_DIR.glob("*.log"):
    size_mb = log_file.stat().st_size / 1_048_576
    if size_mb > 100:
        print(f"Large log: {log_file.name} ({size_mb:.1f} MB)")

# --- Portable path joining (works on Windows and Linux) ---
# os.path style (older)
artifact_dir = os.path.join(os.getcwd(), "build", "artifacts")

# pathlib style (modern — preferred)
artifact_dir = Path.cwd() / "build" / "artifacts"

# --- Useful path operations ---
p = Path("/home/ubuntu/app/config/prod.yaml")
print(p.parent)       # /home/ubuntu/app/config
print(p.stem)         # prod
print(p.suffix)       # .yaml
print(p.name)         # prod.yaml
print(p.parts)        # ('/', 'home', 'ubuntu', 'app', 'config', 'prod.yaml')

# --- Temp files (useful in CI) ---
import tempfile
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
    tmp.write(b'{"key": "value"}')
    tmp_path = Path(tmp.name)
# Process tmp_path, then clean up
tmp_path.unlink()
Q23
How do you use Python's `requests` library to interact with REST APIs in automation scripts?
Basic

Ans:

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import time

# --- Session with retry logic (production-grade) ---
def make_session(retries: int = 3, backoff: float = 0.5) -> requests.Session:
    session = requests.Session()
    retry = Retry(
        total=retries,
        backoff_factor=backoff,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["GET", "POST", "PUT"],
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    return session

session = make_session()

# --- GitHub API example (useful in CI/CD) ---
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
session.headers.update({
    "Authorization": f"Bearer {GITHUB_TOKEN}",
    "Accept": "application/vnd.github.v3+json",
})

# Get PR info
resp = session.get(
    "https://api.github.com/repos/myorg/myapp/pulls/42",
    timeout=10,
)
resp.raise_for_status()
pr = resp.json()
print(f"PR #{pr['number']}: {pr['title']} — {pr['state']}")

# Trigger a workflow dispatch
resp = session.post(
    "https://api.github.com/repos/myorg/myapp/actions/workflows/deploy.yml/dispatches",
    json={"ref": "main", "inputs": {"environment": "prod", "version": "v1.2.3"}},
    timeout=10,
)
resp.raise_for_status()

# --- Rate limiting pattern ---
def api_call_with_rate_limit(url: str, calls_per_second: int = 5):
    interval = 1.0 / calls_per_second
    start = time.monotonic()
    resp = session.get(url, timeout=10)
    resp.raise_for_status()
    elapsed = time.monotonic() - start
    if elapsed < interval:
        time.sleep(interval - elapsed)
    return resp.json()
Q24
How do you write a Python script that reads from AWS SQS and processes messages?
Intermediate

Ans:

import boto3
import json
import logging
import time
import signal
import sys

logger = logging.getLogger(__name__)
sqs = boto3.client("sqs", region_name="us-east-1")
QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/123456789012/my-queue"

# --- Graceful shutdown ---
running = True
def handle_sigterm(sig, frame):
    global running
    logger.info("Shutdown signal received — draining current batch...")
    running = False

signal.signal(signal.SIGTERM, handle_sigterm)
signal.signal(signal.SIGINT, handle_sigterm)


def process_message(body: dict) -> bool:
    """Returns True if processed successfully."""
    event_type = body.get("event_type")
    if event_type == "deploy_request":
        logger.info("Processing deploy: %s", body.get("version"))
        # ... business logic ...
        return True
    logger.warning("Unknown event type: %s", event_type)
    return False


def poll_and_process():
    while running:
        response = sqs.receive_message(
            QueueUrl=QUEUE_URL,
            MaxNumberOfMessages=10,     # Process up to 10 at once
            WaitTimeSeconds=20,         # Long polling — reduces empty receive calls
            VisibilityTimeout=300,      # Message hidden for 5 min while processing
            MessageAttributeNames=["All"],
        )

        messages = response.get("Messages", [])
        if not messages:
            continue

        for msg in messages:
            receipt_handle = msg["ReceiptHandle"]
            try:
                body = json.loads(msg["Body"])
                success = process_message(body)

                if success:
                    # Delete after successful processing
                    sqs.delete_message(QueueUrl=QUEUE_URL, ReceiptHandle=receipt_handle)
                    logger.info("Message processed and deleted: %s", msg["MessageId"])
                else:
                    # Leave in queue to be retried (visibility timeout expires)
                    logger.warning("Processing failed — message will retry")

            except Exception:
                logger.exception("Unhandled error for message %s", msg["MessageId"])
                # DLQ handles it after max receives exceeded


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    poll_and_process()
    logger.info("Worker stopped cleanly")
Q25
How do you use Python to automate AWS IAM operations — creating roles, policies, and users?
Advanced

Ans:

import boto3
import json

iam = boto3.client("iam")

# --- Create a policy ---
policy_document = {
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Action": ["s3:GetObject", "s3:PutObject"],
        "Resource": "arn:aws:s3:::my-bucket/*",
    }],
}

policy_response = iam.create_policy(
    PolicyName="S3ReadWriteMyBucket",
    PolicyDocument=json.dumps(policy_document),
    Description="Allows R/W access to my-bucket",
)
policy_arn = policy_response["Policy"]["Arn"]

# --- Create a role (for EC2 to assume) ---
trust_policy = {
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Principal": {"Service": "ec2.amazonaws.com"},
        "Action": "sts:AssumeRole",
    }],
}

role = iam.create_role(
    RoleName="EC2-S3Access-Role",
    AssumeRolePolicyDocument=json.dumps(trust_policy),
    Description="Allows EC2 instances to access S3",
)
role_arn = role["Role"]["Arn"]

# Attach the policy to the role
iam.attach_role_policy(RoleName="EC2-S3Access-Role", PolicyArn=policy_arn)

# Create instance profile (needed to attach role to EC2)
iam.create_instance_profile(InstanceProfileName="EC2-S3Access-Profile")
iam.add_role_to_instance_profile(
    InstanceProfileName="EC2-S3Access-Profile",
    RoleName="EC2-S3Access-Role",
)

# --- Assume a role (cross-account access) ---
sts = boto3.client("sts")
assumed = sts.assume_role(
    RoleArn="arn:aws:iam::999999999999:role/CrossAccountRole",
    RoleSessionName="ci-pipeline-deploy",
    DurationSeconds=3600,
)
creds = assumed["Credentials"]

# Use the assumed credentials
cross_account_client = boto3.client(
    "ec2",
    region_name="us-east-1",
    aws_access_key_id=creds["AccessKeyId"],
    aws_secret_access_key=creds["SecretAccessKey"],
    aws_session_token=creds["SessionToken"],
)
Q26
How do you use Python to trigger and monitor AWS CodePipeline / CodeBuild?
Advanced

Ans:

import boto3
import time
import sys

codepipeline = boto3.client("codepipeline", region_name="us-east-1")
codebuild    = boto3.client("codebuild", region_name="us-east-1")

PIPELINE_NAME = "myapp-prod-pipeline"

# --- Trigger a pipeline execution ---
response = codepipeline.start_pipeline_execution(name=PIPELINE_NAME)
execution_id = response["pipelineExecutionId"]
print(f"Started pipeline execution: {execution_id}")

# --- Poll until pipeline completes ---
def wait_for_pipeline(name: str, execution_id: str, timeout_s: int = 1800) -> str:
    start = time.monotonic()
    while time.monotonic() - start < timeout_s:
        response = codepipeline.get_pipeline_execution(
            pipelineName=name,
            pipelineExecutionId=execution_id,
        )
        status = response["pipelineExecution"]["status"]
        print(f"Pipeline status: {status}")

        if status in ("Succeeded", "Failed", "Stopped", "Superseded"):
            return status

        time.sleep(30)

    raise TimeoutError(f"Pipeline did not complete within {timeout_s}s")


final_status = wait_for_pipeline(PIPELINE_NAME, execution_id)
if final_status != "Succeeded":
    sys.exit(1)


# --- Trigger CodeBuild directly ---
build_response = codebuild.start_build(
    projectName="myapp-unit-tests",
    environmentVariablesOverride=[
        {"name": "APP_VERSION", "value": "v1.2.3", "type": "PLAINTEXT"},
        {"name": "DEPLOY_ENV", "value": "staging", "type": "PLAINTEXT"},
    ],
)
build_id = build_response["build"]["id"]

# Poll until build completes
while True:
    builds = codebuild.batch_get_builds(ids=[build_id])
    build = builds["builds"][0]
    phase = build["currentPhase"]
    status = build["buildStatus"]
    print(f"Build phase: {phase}, status: {status}")

    if status in ("SUCCEEDED", "FAILED", "STOPPED", "TIMED_OUT", "FAULT"):
        break
    time.sleep(15)

print("Build logs:", build["logs"]["deepLink"])
if status != "SUCCEEDED":
    sys.exit(1)
Q27
How do you write unit tests for Python functions that call AWS services using moto?
Advanced

Ans:

moto is a library that intercepts boto3 calls and simulates AWS services in memory — no real AWS account needed.

# pip install moto boto3 pytest

import json
import pytest
import boto3
from moto import mock_aws

# Code under test
def get_config_from_s3(bucket: str, key: str) -> dict:
    s3 = boto3.client("s3", region_name="us-east-1")
    response = s3.get_object(Bucket=bucket, Key=key)
    return json.loads(response["Body"].read())


def list_running_instances(region: str = "us-east-1") -> list:
    ec2 = boto3.client("ec2", region_name=region)
    paginator = ec2.get_paginator("describe_instances")
    instances = []
    for page in paginator.paginate(
        Filters=[{"Name": "instance-state-name", "Values": ["running"]}]
    ):
        for r in page["Reservations"]:
            instances.extend(r["Instances"])
    return instances


# Tests
@mock_aws
def test_get_config_from_s3():
    # Arrange — set up fake S3
    s3 = boto3.client("s3", region_name="us-east-1")
    s3.create_bucket(Bucket="test-bucket")
    s3.put_object(
        Bucket="test-bucket",
        Key="config/app.json",
        Body=json.dumps({"env": "test", "debug": True}),
    )

    # Act
    config = get_config_from_s3("test-bucket", "config/app.json")

    # Assert
    assert config["env"] == "test"
    assert config["debug"] is True


@mock_aws
def test_list_running_instances_empty():
    result = list_running_instances()
    assert result == []


@mock_aws
def test_list_running_instances_with_data():
    ec2 = boto3.client("ec2", region_name="us-east-1")
    ec2.run_instances(
        ImageId="ami-00000000",
        MinCount=2,
        MaxCount=2,
        InstanceType="t2.micro",
    )

    result = list_running_instances()
    assert len(result) == 2

# Run: pytest test_aws_functions.py -v
Q28
How do you write a Python-based CloudFormation custom resource (Lambda-backed)?
Advanced

Ans:

CloudFormation custom resources let you run arbitrary Python code during stack create/update/delete.

import json
import logging
import urllib.request

logger = logging.getLogger(__name__)

def send_response(event, context, status, data=None, reason=""):
    """Send response back to CloudFormation."""
    body = json.dumps({
        "Status": status,
        "Reason": reason or f"See CloudWatch: {context.log_stream_name}",
        "PhysicalResourceId": event.get("PhysicalResourceId", context.log_stream_name),
        "StackId": event["StackId"],
        "RequestId": event["RequestId"],
        "LogicalResourceId": event["LogicalResourceId"],
        "Data": data or {},
    }).encode("utf-8")

    url = event["ResponseURL"]
    req = urllib.request.Request(url, data=body, method="PUT")
    req.add_header("Content-Type", "")
    req.add_header("Content-Length", len(body))
    urllib.request.urlopen(req)


def lambda_handler(event, context):
    logger.info("Event: %s", json.dumps(event))

    request_type = event["RequestType"]  # Create | Update | Delete
    props = event.get("ResourceProperties", {})

    try:
        if request_type == "Create":
            result = create_resource(props)
            send_response(event, context, "SUCCESS", data=result)

        elif request_type == "Update":
            result = update_resource(props, event.get("OldResourceProperties", {}))
            send_response(event, context, "SUCCESS", data=result)

        elif request_type == "Delete":
            delete_resource(props)
            send_response(event, context, "SUCCESS")

    except Exception as e:
        logger.exception("Custom resource failed")
        send_response(event, context, "FAILED", reason=str(e))


def create_resource(props: dict) -> dict:
    import boto3
    # Example: create a Route53 DNS record
    r53 = boto3.client("route53")
    r53.change_resource_record_sets(
        HostedZoneId=props["HostedZoneId"],
        ChangeBatch={
            "Changes": [{
                "Action": "CREATE",
                "ResourceRecordSet": {
                    "Name": props["RecordName"],
                    "Type": "CNAME",
                    "TTL": 300,
                    "ResourceRecords": [{"Value": props["Target"]}],
                },
            }],
        },
    )
    return {"RecordName": props["RecordName"]}


def delete_resource(props: dict):
    pass  # Clean up on stack deletion
Q29
How do you use Python environment variables and `.env` files securely in DevOps projects?
Basic

Ans:

# pip install python-dotenv

import os
from dotenv import load_dotenv
from pathlib import Path

# Load .env file (does NOT override existing env vars by default)
load_dotenv()

# Load a specific env file
load_dotenv(Path(__file__).parent / ".env.production")

# Override existing variables
load_dotenv(override=True)

# --- Access values with validation ---
def require_env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise EnvironmentError(f"Required environment variable '{name}' is not set")
    return value

DATABASE_URL = require_env("DATABASE_URL")
SECRET_KEY   = require_env("SECRET_KEY")
DEBUG        = os.environ.get("DEBUG", "false").lower() == "true"
PORT         = int(os.environ.get("PORT", "8080"))

# --- Validate all required env vars at startup ---
REQUIRED_VARS = ["DATABASE_URL", "SECRET_KEY", "AWS_REGION", "S3_BUCKET"]

missing = [v for v in REQUIRED_VARS if not os.environ.get(v)]
if missing:
    raise EnvironmentError(f"Missing required environment variables: {missing}")

.env file example:

# .env — NEVER commit this file to git
DATABASE_URL=postgresql://user:password@localhost/mydb
SECRET_KEY=super-secret-key-here
AWS_REGION=us-east-1
S3_BUCKET=my-app-bucket

Security checklist:

  • Add .env to .gitignore immediately
  • Store secrets in AWS Secrets Manager or SSM Parameter Store in production
  • Use .env.example (with dummy values) committed to git as documentation
  • In CI/CD, inject secrets via pipeline secret variables (GitHub Actions secrets, GitLab CI variables) — never in .env files in the repo
Q30
How do you write a Python script to generate an AWS cost report using the Cost Explorer API?
Advanced

Ans:

import boto3
import json
from datetime import date, timedelta
from collections import defaultdict

ce = boto3.client("ce", region_name="us-east-1")  # Cost Explorer is global but uses us-east-1

def get_cost_by_service(days: int = 30) -> dict:
    end   = date.today().isoformat()
    start = (date.today() - timedelta(days=days)).isoformat()

    response = ce.get_cost_and_usage(
        TimePeriod={"Start": start, "End": end},
        Granularity="MONTHLY",
        Metrics=["UnblendedCost"],
        GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
    )

    costs = {}
    for result in response["ResultsByTime"]:
        for group in result["Groups"]:
            service = group["Keys"][0]
            amount  = float(group["Metrics"]["UnblendedCost"]["Amount"])
            costs[service] = costs.get(service, 0) + amount

    return dict(sorted(costs.items(), key=lambda x: x[1], reverse=True))


def get_daily_costs(days: int = 7) -> list:
    end   = date.today().isoformat()
    start = (date.today() - timedelta(days=days)).isoformat()

    response = ce.get_cost_and_usage(
        TimePeriod={"Start": start, "End": end},
        Granularity="DAILY",
        Metrics=["UnblendedCost"],
    )

    return [
        {
            "date":  r["TimePeriod"]["Start"],
            "cost":  round(float(r["Total"]["UnblendedCost"]["Amount"]), 2),
        }
        for r in response["ResultsByTime"]
    ]


def send_cost_report():
    costs = get_cost_by_service(days=30)
    daily = get_daily_costs(days=7)
    total = sum(costs.values())

    report_lines = [f"AWS Cost Report — last 30 days\nTotal: ${total:.2f}\n"]
    report_lines.append("By Service:")
    for svc, cost in list(costs.items())[:10]:   # Top 10
        report_lines.append(f"  {svc:<40} ${cost:>8.2f}")

    report_lines.append("\nDaily (last 7 days):")
    for day in daily:
        report_lines.append(f"  {day['date']}  ${day['cost']:.2f}")

    report = "\n".join(report_lines)
    print(report)

    # Send to SNS
    sns = boto3.client("sns", region_name="us-east-1")
    sns.publish(
        TopicArn="arn:aws:sns:us-east-1:123456789012:cost-reports",
        Subject="AWS Weekly Cost Report",
        Message=report,
    )


if __name__ == "__main__":
    send_cost_report()