121. Psutil
System resource monitoring with psutil
121. Psutil
🖥️ Aryan is building a RAM manager. He wants to show total RAM, used RAM, and a list of top processes sorted by memory usage. But he’s using the wrong psutil attributes and forgetting to handle processes that disappear mid-scan.
💡 Fun fact: psutil (process and system utilities) wraps OS-level kernel APIs — vm_stat on macOS, /proc/meminfo on Linux, GlobalMemoryStatusEx on Windows — in a single, cross-platform Python interface. It was created by Giampaolo Rodola in 2009 and has over 10 million monthly downloads on PyPI. The rss (Resident Set Size) metric reports how much physical RAM a process is actually occupying right now, as opposed to vms (Virtual Memory Size) which counts address space that may never actually be loaded into RAM.
⚠️ Watch out: mem.free and mem.available are not the same thing. On Linux and macOS, the OS aggressively caches recently read disk data in RAM — this “cache” memory appears as “used” but is instantly reclaimable. mem.free reports only the strictly unallocated RAM (often surprisingly small), while mem.available reports free RAM plus reclaimable cache — the amount a new process could actually obtain. Always use mem.available for “how much RAM can I still use” questions.
🤔 Think about it: Process scanning with psutil.process_iter() is a race condition: a process can die between the moment the iterator lists it and the moment you access its .info. This is why NoSuchProcess must be caught. What does this tell you about the reliability of any “snapshot” of system state? If you were building a RAM monitor that alerts when usage exceeds 90%, how would you design it to avoid false alerts from race conditions?
Learning objectives
- Use mem.available (not mem.free) for usable RAM
- Iterate processes safely with try/except for NoSuchProcess and AccessDenied
- Sort process list descending by RSS to find memory hogs
- Convert bytes to MB/GB for human-readable output
Key concepts
- psutil.virtual_memory() — system RAM stats
- mem.available vs mem.free — correct free memory
- psutil.process_iter() — live process scan
- NoSuchProcess / AccessDenied — race condition handling
- rss (Resident Set Size) — actual RAM usage per process
Try it
Concept detail
System Monitoring with psutil
psutil (process and system utilities) wraps OS-level APIs in cross-platform Python.
The Hard Way: subprocess
Before psutil existed, you’d shell out to OS commands. This still works, but it’s fragile and OS-specific:
import subprocess, re
# macOS / Linux: read total and used RAM from vm_stat / free
result = subprocess.run(
['ps', 'aux', '--sort=-%mem'], # Linux only
capture_output=True, text=True, check=True
)
for line in result.stdout.splitlines()[1:11]: # top 10 processes
parts = line.split(None, 10)
pid, cpu, mem_pct, vsz, rss = parts[1], parts[2], parts[3], parts[4], parts[5]
name = parts[10].split()[0]
print(f'{int(rss) // 1024:>6} MB {name} (PID {pid})')Problems with subprocess:
ps aux --sort=-%memonly works on Linux (not macOS, not Windows)- RSS from
psis in KB; unit parsing is manual and error-prone - No built-in way to filter
AccessDenied— you get garbled rows - Spawns a new process for every call — slow in a monitoring loop
The Easy Way: psutil
import psutil
mem = psutil.virtual_memory()
mem.total # total installed RAM (bytes)
mem.used # RAM in use
mem.available # RAM available for new processes (includes reclaimable cache)
mem.free # strictly unused RAM — usually misleadingly low
mem.percent # (total - available) / total × 100Always use mem.available, not mem.free. On Linux/macOS the OS caches disk reads in RAM — this appears as “used” but is instantly reclaimable. mem.available accounts for this correctly.
Process Iteration
for proc in psutil.process_iter(['pid', 'name', 'memory_info']):
try:
rss = proc.info['memory_info'].rss # Resident Set Size
except (psutil.NoSuchProcess, psutil.AccessDenied):
passNoSuchProcess— process died between the iterator finding it and you reading itAccessDenied— system/kernel processes, restricted by OS permissionsrss(Resident Set Size) = actual RAM consumed; more useful thanvms(virtual)
psutil vs subprocess comparison
| Feature | subprocess + ps/free | psutil |
|---|---|---|
| Cross-platform | OS-specific flags | macOS/Linux/Windows |
| RSS units | KB string, manual parse | bytes integer, always correct |
| AccessDenied handling | garbled output | clean exception |
| Speed | Spawns new process each call | Direct kernel call |
| Installation | Built-in | pip install psutil |
Sorting Top Processes
top = sorted(procs, key=lambda p: p['rss_mb'], reverse=True)[:10]Solution
import psutil
def get_memory_summary() -> dict:
"""Return total, used, and available RAM in GB."""
mem = psutil.virtual_memory()
return {
'total_gb': round(mem.total / 1e9, 1),
'used_gb': round(mem.used / 1e9, 1),
'available_gb': round(mem.available / 1e9, 1), # available includes reclaimable cache
'percent': mem.percent,
}
def get_top_processes(n: int = 5) -> list:
"""Return top n processes by RSS memory, sorted descending."""
results = []
for proc in psutil.process_iter(['pid', 'name', 'memory_info']):
try:
rss = proc.info['memory_info'].rss
results.append({'pid': proc.info['pid'],
'name': proc.info['name'],
'rss_mb': round(rss / 1e6, 1)})
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass # process died or we lack permission — skip it
return sorted(results, key=lambda p: p['rss_mb'], reverse=True)[:n]Tests
from unittest.mock import patch, MagicMock
def _make_mem(total, used, available, free, percent):
m = MagicMock()
m.total = total
m.used = used
m.available = available
m.free = free
m.percent = percent
return m
def _make_proc(pid, name, rss):
p = MagicMock()
p.info = {'pid': pid, 'name': name, 'memory_info': MagicMock(rss=rss)}
return p
def test_summary_uses_available_not_free():
mem = _make_mem(
total=8_000_000_000,
used=5_000_000_000,
available=2_500_000_000,
free=500_000_000, # free < available (cache in between)
percent=62.5,
)
with patch('psutil.virtual_memory', return_value=mem):
result = get_memory_summary()
# Must use available (2.5 GB), not free (0.5 GB)
assert result['available_gb'] == 2.5, (
f"Expected 2.5 GB (available), got {result['available_gb']} — "
"use mem.available, not mem.free"
)
def test_summary_total_and_percent():
mem = _make_mem(8_000_000_000, 4_000_000_000, 4_000_000_000, 4_000_000_000, 50.0)
with patch('psutil.virtual_memory', return_value=mem):
result = get_memory_summary()
assert result['total_gb'] == 8.0
assert result['percent'] == 50.0
def test_top_processes_sorted_descending():
procs = [
_make_proc(1, 'chrome', 500_000_000),
_make_proc(2, 'python', 100_000_000),
_make_proc(3, 'code', 300_000_000),
]
with patch('psutil.process_iter', return_value=procs):
result = get_top_processes(3)
assert result[0]['name'] == 'chrome', "Highest RSS should be first"
assert result[1]['name'] == 'code'
assert result[2]['name'] == 'python'
def test_top_processes_handles_no_such_process():
"""get_top_processes must catch NoSuchProcess and skip the dead process."""
good_proc = _make_proc(42, 'vim', 50_000_000)
class FakeDeadProc:
"""A process that raises NoSuchProcess when .info is accessed."""
@property
def info(self):
raise psutil.NoSuchProcess(pid=0)
with patch('psutil.process_iter', return_value=[FakeDeadProc(), good_proc]):
result = get_top_processes(5)
# Only the good proc should appear; dead proc was skipped
assert len(result) == 1
assert result[0]['name'] == 'vim'
def test_top_processes_handles_access_denied():
"""get_top_processes must catch AccessDenied and skip the restricted process."""
good_proc = _make_proc(1, 'bash', 20_000_000)
class FakeRestrictedProc:
@property
def info(self):
raise psutil.AccessDenied(pid=0)
with patch('psutil.process_iter', return_value=[FakeRestrictedProc(), good_proc]):
result = get_top_processes(5)
assert len(result) == 1
assert result[0]['name'] == 'bash'
def test_top_processes_respects_n():
procs = [_make_proc(i, f'proc{i}', i * 10_000_000) for i in range(10)]
with patch('psutil.process_iter', return_value=procs):
result = get_top_processes(3)
assert len(result) == 3