2026, Jan 09 23:00

Align Matplotlib grid lines with your time-series points: match ticks to timestamps or normalize to midnight

Learn why Matplotlib time-series grid lines miss points and fix it: align ticks to UTC datetimes or normalize to midnight. With pandas and DateFormatter tips.

Vertical grid lines that don’t pass through plotted points are a classic pitfall in time series charts. The data carries timestamps down to seconds, while the x-axis is formatted to show only calendar dates. As a result, ticks and grid lines fall at day boundaries, not at the exact instants where measurements were taken.

Reproducing the issue

The dataset contains timezone-aware UTC datetimes with evening times, and the axis shows only dates. The following snippet demonstrates the mismatch between grid lines and markers.

import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
from datetime import datetime, timezone

data_map = {
    datetime(2025, 4, 15, 19, 23, 50, 658000, tzinfo=timezone.utc): 68.0,
    datetime(2025, 4, 16, 19, 31, 1, 367000, tzinfo=timezone.utc): 72.0,
    datetime(2025, 4, 17, 19, 34, 21, 507000, tzinfo=timezone.utc): 75.0,
    datetime(2025, 4, 18, 19, 50, 28, 446000, tzinfo=timezone.utc): 80.0,
    datetime(2025, 4, 19, 19, 57, 15, 393000, tzinfo=timezone.utc): 78.0,
    datetime(2025, 4, 20, 19, 57, 49, 60000, tzinfo=timezone.utc): 77.0,
    datetime(2025, 4, 21, 20, 28, 51, 127710, tzinfo=timezone.utc): 73.0,
}

frame = pd.DataFrame(list(data_map.items()), columns=["date", "weight"])

fig_obj, chart = plt.subplots(figsize=(12, 6))
chart.plot(frame["date"], frame["weight"], marker='o', linestyle='-', color='royalblue', label='Weight')
chart.scatter(frame["date"], frame["weight"], color='red', zorder=5)

chart.set_title('Weight change by day', fontsize=16)
chart.set_xlabel('Date', fontsize=12)
chart.set_ylabel('Weight (kg)', fontsize=12)

chart.xaxis.set_major_locator(mdates.AutoDateLocator())
chart.xaxis.set_major_formatter(mdates.DateFormatter('%d.%m.%Y'))

plt.setp(chart.xaxis.get_majorticklabels(), rotation=45, ha='right')
chart.grid(True, linestyle='--', alpha=0.6)

plt.tight_layout()
plt.show()

Why the grid lines miss the points

The x-axis is driven by date locators and a formatter that prints only the day, month, and year. Your datapoints occur around 19:23–20:28 UTC, while major ticks commonly land at midnight. Grid lines follow those tick locations. If a label shows only the date, it looks correct at a glance, but the underlying position is 00:00 of that day. With datapoints at evening times, the markers don’t sit under the grid lines.

Change one timestamp to midnight and you’ll see it suddenly align with a vertical grid line for that day.

Two ways to fix it

Pick the approach that matches the intent of the chart. If the time of day matters, align ticks with the actual timestamps. If you are showing daily trends only, normalize all timestamps to midnight so days and markers share the same reference.

When the time of day matters, force ticks to your actual datetimes. This keeps grid lines exactly under every point.

import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
from datetime import datetime, timezone

records = {
    datetime(2025, 4, 15, 19, 23, 50, 658000, tzinfo=timezone.utc): 68.0,
    datetime(2025, 4, 16, 19, 31, 1, 367000, tzinfo=timezone.utc): 72.0,
    datetime(2025, 4, 17, 19, 34, 21, 507000, tzinfo=timezone.utc): 75.0,
    datetime(2025, 4, 18, 19, 50, 28, 446000, tzinfo=timezone.utc): 80.0,
    datetime(2025, 4, 19, 19, 57, 15, 393000, tzinfo=timezone.utc): 78.0,
    datetime(2025, 4, 20, 19, 57, 49, 60000, tzinfo=timezone.utc): 77.0,
    datetime(2025, 4, 21, 20, 28, 51, 127710, tzinfo=timezone.utc): 73.0,
}

df_full = pd.DataFrame(list(records.items()), columns=['date', 'weight'])

fig_box, ax_box = plt.subplots(figsize=(12, 6), layout='constrained')

ax_box.plot(
    df_full['date'], df_full['weight'],
    marker='o', markersize=8,
    markerfacecolor='red', markeredgecolor='white', markeredgewidth=1.5,
    linestyle='-', linewidth=2, color='royalblue', label='Weight'
)

ax_box.set_xticks(df_full['date'])
ax_box.xaxis.set_major_formatter(mdates.DateFormatter('%d.%m.%Y\n%H:%M'))

ax_box.grid(True, which='major', linestyle='--', linewidth=0.7, alpha=0.7)

ax_box.set_title('Weight change by day', fontsize=16, pad=20)
ax_box.set_xlabel('Measurement datetime', fontsize=12, labelpad=10)
ax_box.set_ylabel('Weight (kg)', fontsize=12, labelpad=10)

ax_box.tick_params(axis='x', which='major', rotation=45, labelsize=10)

fig_box.autofmt_xdate(ha='center', bottom=0.2)
plt.show()

When you only need daily trends, normalize datetimes to midnight. In this case, aligning everything to 00:00 makes the day ticks and grid lines match the points. The example also removes timezone information before normalization.

import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
import datetime

points = {
    datetime.datetime(2025, 4, 15, 19, 23, 50, 658000, tzinfo=datetime.timezone.utc): 68.0,
    datetime.datetime(2025, 4, 16, 19, 31, 1, 367000, tzinfo=datetime.timezone.utc): 72.0,
    datetime.datetime(2025, 4, 17, 19, 34, 21, 507000, tzinfo=datetime.timezone.utc): 75.0,
    datetime.datetime(2025, 4, 18, 19, 50, 28, 446000, tzinfo=datetime.timezone.utc): 80.0,
    datetime.datetime(2025, 4, 19, 19, 57, 15, 393000, tzinfo=datetime.timezone.utc): 78.0,
    datetime.datetime(2025, 4, 20, 19, 57, 49, 60000, tzinfo=datetime.timezone.utc): 77.0,
    datetime.datetime(2025, 4, 21, 20, 28, 51, 127710, tzinfo=datetime.timezone.utc): 73.0,
}

series = pd.DataFrame(list(points.items()), columns=["date", "weight"])
series['date'] = series['date'].dt.tz_convert(None).dt.normalize()

fig_fix, ax_fix = plt.subplots(figsize=(12, 6))
ax_fix.plot(series['date'], series['weight'], marker='o', linestyle='-', color='royalblue', label='Weight')
ax_fix.scatter(series['date'], series['weight'], color='red', zorder=5)

ax_fix.set_title('Weight change by day', fontsize=16)
ax_fix.set_xlabel('Date', fontsize=12)
ax_fix.set_ylabel('Weight (kg)', fontsize=12)

ax_fix.xaxis.set_major_locator(mdates.DayLocator())
ax_fix.xaxis.set_major_formatter(mdates.DateFormatter('%d.%m.%Y'))

plt.setp(ax_fix.xaxis.get_majorticklabels(), rotation=45, ha='right')
ax_fix.grid(True, linestyle='--', alpha=0.6)

plt.tight_layout()
plt.show()

Why this matters

A mismatch between ticks and datapoints easily misleads readers about when events occurred. Controlling tick placement and datetime normalization removes that ambiguity. When the chart needs to reflect the exact measurement times, ticks should honor the true timestamps. When the goal is daily aggregation, normalizing to midnight keeps the visual story consistent with the scale.

Takeaways

If the chart is about exact instants, align ticks to the true datetime values and display both date and time on the x-axis. If the chart represents daily trends, normalize timestamps to midnight and use a daily locator. Either way, you get grid lines that actually mark the same instants your points represent, which is exactly what a reader expects to see.