← Home

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=-%mem only works on Linux (not macOS, not Windows)
  • RSS from ps is 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 × 100

Always 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):
        pass
  • NoSuchProcess — process died between the iterator finding it and you reading it
  • AccessDenied — system/kernel processes, restricted by OS permissions
  • rss (Resident Set Size) = actual RAM consumed; more useful than vms (virtual)

psutil vs subprocess comparison

Featuresubprocess + ps/freepsutil
Cross-platformOS-specific flagsmacOS/Linux/Windows
RSS unitsKB string, manual parsebytes integer, always correct
AccessDenied handlinggarbled outputclean exception
SpeedSpawns new process each callDirect kernel call
InstallationBuilt-inpip 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

Resources