← Back to DevBytes

Implementing NTP Protocol: From Theory to Practice

What is NTP?

The Network Time Protocol (NTP) is one of the oldest, most widely deployed protocols on the internet. Its job is deceptively simple: synchronize the clocks of networked computers to within a few milliseconds of Coordinated Universal Time (UTC). First designed by David Mills in 1985, NTP now operates across billions of devices, from cloud servers and IoT sensors to financial trading systems and power grid controllers.

At its core, NTP is a hierarchical, redundant, and self‑correcting system. A device acting as an NTP client sends UDP packets to one or more NTP servers, requesting the current time. The server replies with timestamped packets. Using the round‑trip delay and an offset estimation algorithm, the client can continuously discipline its local clock, compensating for drift, asymmetry, and network jitter.

Why NTP Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Accurate time is a hidden dependency in almost every distributed system. Without precise synchronization, causality collapses, log entries become meaningless, cryptographic certificate validity becomes unreliable, and distributed databases face consistency failures. Here are just a few examples:

Even a simple web application benefits: server logs merge correctly, cron jobs fire at the expected wall‑clock moment, and OAuth tokens don’t expire prematurely. Implementing NTP correctly, whether as a client or server, is a foundational skill for any developer working on networked software.

NTP Protocol Fundamentals

NTP uses a 48‑byte UDP packet. The structure is defined in RFC 5905 (NTPv4) and earlier standards. Understanding the packet format is essential for any implementation.

Packet Structure

The NTP packet is divided into a header and several timestamp fields. Here is the layout (offsets in bytes):

Modes matter: client (3), server (4), symmetric active (1), symmetric passive (2), broadcast (5). For a basic client‑server exchange we use mode 3 (client) and 4 (server).

How Time Synchronization Works

The client records the time T1 when it sends the request. The server records T2 (arrival) and T3 (departure) and places them in the response. The client records T4 upon receiving the response. Using these four timestamps, we compute:

Offset = ((T2 - T1) + (T3 - T4)) / 2
Round‑trip delay = (T4 - T1) - (T3 - T2)

The offset estimates how far the client clock is ahead or behind the server clock. The client can then adjust its local clock gradually (slewing) or step it, depending on the magnitude. NTP daemons typically use a phase‑locked loop (PLL) or frequency‑locked loop (FLL) to discipline the clock, avoiding abrupt jumps that could confuse applications.

NTP Timestamp Format

NTP timestamps are 64‑bit fixed‑point numbers: 32 bits for seconds since 1 January 1900, and 32 bits for fractional seconds. This gives a resolution of about 233 picoseconds. In practice, many implementations convert to and from Unix epoch (seconds since 1970) by subtracting the constant 2208988800.

Implementing an NTP Client in Python

Let’s build a functional NTP client that queries a public NTP server, parses the response, computes offset and delay, and optionally adjusts the system clock. We’ll use Python’s standard library only, for maximum portability.

Step 1: Construct the Request Packet

The client must send a 48‑byte UDP packet with the LI, Version, and Mode fields set appropriately. The simplest request is all zeros except for the first byte: 0x23 for version 4, mode 3. The rest of the packet can be zero‑padded.


import struct

def create_ntp_request():
    # LI = 0 (no warning), VN = 4, Mode = 3 (client)
    # First byte: 00 100 011 = 0x23
    # All other fields can be 0 for a basic request.
    packet = bytearray(48)
    packet[0] = 0x23  # LI=0, VN=4, Mode=3
    # Remaining bytes already 0
    return bytes(packet)

Step 2: Send Request and Receive Response

We use a UDP socket with a timeout. The server’s response will be exactly 48 bytes.


import socket
import time

def send_ntp_request(server, port=123, timeout=3):
    request = create_ntp_request()
    # Record T1 (client transmit timestamp) right before sending
    # We'll use time.time() and convert to NTP format later.
    t1 = time.time()  # seconds since 1970

    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.settimeout(timeout)
    sock.sendto(request, (server, port))

    # Wait for response
    data, addr = sock.recvfrom(1024)
    t4 = time.time()  # client receive timestamp
    sock.close()

    if len(data) < 48:
        raise ValueError("Short packet received")

    return data, t1, t4

Step 3: Parse the NTP Response

The response contains the server’s receive and transmit timestamps. We need to extract them from the correct byte offsets and convert from NTP format to seconds since 1970.


# Constants
NTP_DELTA = 2208988800  # difference between NTP epoch (1900) and Unix epoch (1970)

def ntp_to_unix(ntp_seconds, ntp_fraction):
    # Combine seconds (32 bits) and fraction (32 bits) into a float
    seconds_since_1900 = ntp_seconds + ntp_fraction / 2**32
    return seconds_since_1900 - NTP_DELTA

def parse_ntp_response(data, t1, t4):
    # Unpack first 48 bytes according to RFC 5905
    # We ignore the header fields except timestamps
    # Format: ! = network byte order, I = unsigned int 32, 4x = skip 4 bytes
    unpacked = struct.unpack('!12I', data[:48])

    # The timestamps are at indices:
    # 10,11: Reference Timestamp (seconds, fraction) - skip if not needed
    # 12,13: Originate Timestamp (server echoes client's T1)
    # 14,15: Receive Timestamp (T2, server arrival)
    # 16,17: Transmit Timestamp (T3, server departure)
    # Indices 0-9 are header fields.
    # Actually let's unpack selectively to be clearer.

    # Better: unpack header as 4 bytes + 4 bytes root delay, etc.
    # We'll just extract the timestamps from known positions.
    # Bytes 24-31: Originate Timestamp (seconds + fraction)
    # Bytes 32-39: Receive Timestamp
    # Bytes 40-47: Transmit Timestamp

    # Use struct.unpack_from to get 32-bit integers
    t1_sec = struct.unpack_from('!I', data, 24)[0]
    t1_frac = struct.unpack_from('!I', data, 28)[0]
    t2_sec = struct.unpack_from('!I', data, 32)[0]
    t2_frac = struct.unpack_from('!I', data, 36)[0]
    t3_sec = struct.unpack_from('!I', data, 40)[0]
    t3_frac = struct.unpack_from('!I', data, 44)[0]

    # Convert to Unix float
    # The Originate Timestamp in response is what the client sent, but server
    # fills it with the T1 value it received (which may be slightly different
    # due to network). We'll use our own T1 for offset calculation.
    # For standard NTP, T1 = client transmit, T2 = server receive,
    # T3 = server transmit, T4 = client receive.
    T2 = ntp_to_unix(t2_sec, t2_frac)
    T3 = ntp_to_unix(t3_sec, t3_frac)

    return T2, T3, t1, t4

Step 4: Compute Offset and Delay


def compute_offset_delay(T2, T3, t1, t4):
    # Using the formulas:
    # offset = ((T2 - t1) + (T3 - t4)) / 2
    # delay = (t4 - t1) - (T3 - T2)
    offset = ((T2 - t1) + (T3 - t4)) / 2
    delay = (t4 - t1) - (T3 - T2)
    return offset, delay

Step 5: Complete Client with Clock Adjustment (Optional)

On Unix‑like systems, you can adjust the system clock using the adjtime() call, which gradually slews the clock. This requires root privileges. For demonstration, we’ll print the offset and delay, and show how you’d call adjtime if running as root.


import sys
import ctypes

# Linux-specific adjtime wrapper (requires root)
def adjtime_correction(offset_sec):
    # Load the C library
    libc = ctypes.CDLL('libc.so.6', use_errno=True)
    # struct timeval { long tv_sec; long tv_usec; }
    class timeval(ctypes.Structure):
        _fields_ = [("tv_sec", ctypes.c_long),
                    ("tv_usec", ctypes.c_long)]
    tv = timeval(int(offset_sec), int((offset_sec - int(offset_sec)) * 1e6))
    ret = libc.adjtime(ctypes.byref(tv), None)
    return ret

def main():
    server = "pool.ntp.org"
    try:
        data, t1, t4 = send_ntp_request(server)
        T2, T3, _, _ = parse_ntp_response(data, t1, t4)
        offset, delay = compute_offset_delay(T2, T3, t1, t4)
        print(f"Server: {server}")
        print(f"Offset: {offset*1000:.3f} ms")
        print(f"Round-trip delay: {delay*1000:.3f} ms")

        # Attempt clock correction (will likely fail without root)
        if sys.platform.startswith('linux') and os.geteuid() == 0:
            print("Applying offset to system clock...")
            adjtime_correction(offset)
            print("Clock adjustment scheduled.")
        else:
            print("Note: Run as root to adjust system clock.")
    except Exception as e:
        print(f"Error: {e}")

if __name__ == "__main__":
    import os
    main()

This client is functional and demonstrates the core NTP logic. For production, you would query multiple servers, filter outliers, and implement a clock discipline loop rather than a single‑shot correction.

Implementing a Simple NTP Server

A basic NTP server listens on UDP port 123 and responds with a packet containing its own timestamps. The server must fill the Originate Timestamp with the value from the client request (T1), set Receive Timestamp (T2) to the time of arrival, and Transmit Timestamp (T3) to the time just before sending.


import socket
import struct
import time

NTP_DELTA = 2208988800

def unix_to_ntp(seconds_since_1970):
    # Convert to NTP epoch (1900)
    seconds = seconds_since_1970 + NTP_DELTA
    int_part = int(seconds)
    frac_part = int((seconds - int_part) * 2**32)
    return int_part, frac_part

def run_server(host='0.0.0.0', port=123):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((host, port))
    print(f"NTP server listening on {host}:{port}")
    while True:
        data, addr = sock.recvfrom(48)
        if len(data) < 48:
            continue

        # Record arrival time (T2) as soon as possible after recv
        t2 = time.time()
        # Extract client's transmit timestamp (T1) from the request
        # In a real server we'd read the Originate Timestamp from the packet,
        # but for simplicity we can use the current time as reference.
        # Actually the request packet's transmit timestamp is at bytes 40-47
        # if the client set it. But our client didn't fill it.
        # We'll construct a proper response using the client's T1 if present.

        # For this simple demo, we'll assume the client's T1 is the arrival time
        # (which is not accurate but works for basic demonstration).
        # We'll fill the response packet.
        response = bytearray(48)
        # LI=0, VN=4, Mode=4 (server)
        response[0] = 0x24
        # Stratum: 1 (primary reference) for simplicity
        response[1] = 1
        # Other header fields can be zero

        # Record transmit time (T3) just before sending
        t3 = time.time()
        # Convert to NTP format
        t2_sec, t2_frac = unix_to_ntp(t2)
        t3_sec, t3_frac = unix_to_ntp(t3)

        # Pack timestamps: Originate (we echo client's T1 if available, else use 0)
        # Let's assume client T1 is the time in the request packet bytes 40-47.
        # If not set, we put zeros.
        # We'll copy request bytes 40-47 to response 24-31 (Originate)
        response[24:32] = data[40:48]
        # Receive Timestamp (bytes 32-39)
        struct.pack_into('!II', response, 32, t2_sec, t2_frac)
        # Transmit Timestamp (bytes 40-47)
        struct.pack_into('!II', response, 40, t3_sec, t3_frac)

        sock.sendto(bytes(response), addr)

This server is intentionally minimal. A production‑grade server would need to handle stratum, reference clocks, leap‑second flags, and proper authentication (NTPv4 supports symmetric key crypto). Running a public server also means participating in the NTP pool with accurate time sources.

Best Practices for NTP Implementation

1. Query Multiple Servers and Filter Outliers

Never rely on a single time source. Use at least four servers and apply the intersection algorithm or a simple median filter to discard obviously wrong sources. NTP’s core selection algorithm combines offset samples and selects the best candidate based on stratum, root distance, and consistency.

2. Avoid Stepping the Clock Abruptly

Applications expect time to be monotonic. Jumping the clock backward can break timeouts, sequence numbers, and caches. Use adjtime() (slewing) for small corrections and only step the clock during startup if the offset is huge (minutes). On Linux, adjtimex() gives finer control over frequency adjustment.

3. Handle Leap Seconds Gracefully

Leap seconds are inserted occasionally to keep UTC in sync with astronomical time. NTP distributes leap‑second warnings via the LI field. Your implementation should be aware of leap‑second files (often in /usr/share/zoneinfo) and handle the “smear” approach used by Google and others, or step at the exact moment as recommended by the IERS.

4. Security: Disable Monlist and Use NTPsec

The classic ntpd implementation had a monlist command that could be exploited for DDoS amplification. Modern deployments should use ntpsec, chrony, or implement rate limiting and not expose control queries. Always bind to specific interfaces, restrict queries with firewall rules, and consider NTP over TLS (NTS) for encryption.

5. Use High‑Resolution Timestamps

On Linux, enable SO_TIMESTAMPING for hardware‑assisted receive timestamps. In your code, capture T2 and T4 as close to the network stack as possible. Avoid Python overhead by using low‑level C or kernel bypass if microsecond accuracy is required. For most applications, millisecond precision from user space is sufficient.

6. Implement a Clock Discipline Loop

A single offset measurement is not enough. You must run a control loop (PLL/FLL) that adjusts the clock frequency based on the history of offsets. Libraries like libchrony or ntp‑core can be integrated. At minimum, compute a moving average of offsets and apply small corrections periodically.

7. Log and Monitor

Track offset, delay, drift, and server reachability over time. Sudden spikes may indicate network congestion, server misconfiguration, or an ongoing attack. Export metrics to your observability stack (Prometheus, Grafana) and alert when offset exceeds a threshold.

Conclusion

Implementing NTP from scratch demystifies one of the internet’s most critical infrastructure protocols. By understanding the packet format, timestamp exchange, and clock discipline algorithms, you gain the ability to build robust time‑aware systems. Whether you embed a lightweight client in an IoT device, write a custom server for a private network, or simply debug synchronization issues in a distributed system, the principles covered here form the foundation. Always prefer battle‑tested implementations like ntpsec or chrony for production, but use your own code to learn, experiment, and innovate. Accurate time is a shared responsibility—implement it wisely.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles