A script on my laptop pulls a day of clips off an old phone camera, packs them into a zip with Python's zipfile, and a second script on another machine unpacks them and files each clip by its modification time. The Python documentation says extract() handles a member's metadata like this:
"Its file information is extracted as accurately as possible."
I tested what that means for the one piece of file information a clip archive cares about, the time it was recorded. On Python 3.10.12, a zip written and read with zipfile came back with every clip dated the moment it was unpacked. Reading the same zip with Info-ZIP unzip got the times back, but one second early on half the clips, and five hours late once the zip crossed from a machine set to UTC to one set to New York time.
The test folder
Six fake clips, 1 KiB of random bytes each, ten minutes and one second apart, so three of them land on an odd second. The times are fixed, so every run gives the same numbers. My shell is set to New York time; scripts that care set TZ themselves.
# Build a folder of six fake clips with fixed modification times,
# three of them on an odd second, and print what was set.
import os, shutil
shutil.rmtree("clips", ignore_errors=True)
os.makedirs("clips")
base = 1763211601 # a fixed instant, so every run matches
for i in range(6):
p = "clips/cam1_%02d.mp4" % i
with open(p, "wb") as f:
f.write(os.urandom(1024))
t = base + i * 601 # 601 s apart: odd, even, odd, ...
os.utime(p, (t, t))
print(p, t)
clips/cam1_00.mp4 1763211601
clips/cam1_01.mp4 1763212202
clips/cam1_02.mp4 1763212803
clips/cam1_03.mp4 1763213404
clips/cam1_04.mp4 1763214005
clips/cam1_05.mp4 1763214606
zipfile in, zipfile out
The obvious round trip: ZipFile.write() for each clip, then extractall() on the other side.
# Zip the folder with zipfile, extract it with zipfile, compare times.
import os, time, zipfile, shutil
with zipfile.ZipFile("clips_py.zip", "w") as z:
for n in sorted(os.listdir("clips")):
z.write(os.path.join("clips", n), n)
shutil.rmtree("out_py", ignore_errors=True)
time.sleep(2) # so "now" is clearly not the old time
with zipfile.ZipFile("clips_py.zip") as z:
z.extractall("out_py")
now = time.time()
for n in sorted(os.listdir("clips")):
a = os.stat(os.path.join("clips", n)).st_mtime
b = os.stat(os.path.join("out_py", n)).st_mtime
print("%s original time kept=%s dated the moment of extraction=%s"
% (n, a == b, abs(now - b) < 5))
cam1_00.mp4 original time kept=False dated the moment of extraction=True
cam1_01.mp4 original time kept=False dated the moment of extraction=True
cam1_02.mp4 original time kept=False dated the moment of extraction=True
cam1_03.mp4 original time kept=False dated the moment of extraction=True
cam1_04.mp4 original time kept=False dated the moment of extraction=True
cam1_05.mp4 original time kept=False dated the moment of extraction=True
Every extracted clip is dated the second it was unpacked. The original times are in the archive, as the next sections show, but extractall() on this version does not put them back on the files. A job that sorts clips by modification time, or deletes anything older than a week, now sees six clips recorded a moment ago. I read the 3.10 source for _extract_member to confirm it: it copies the bytes and does not call os.utime. Newer versions may differ; I only ran 3.10.
The same zip through unzip
Info-ZIP unzip does restore modification times. Here it is on the zip from the previous step:
#!/bin/bash
# Same Python-made zip, extracted by Info-ZIP unzip instead.
rm -rf out_unzip && unzip -q clips_py.zip -d out_unzip
for f in clips/*.mp4; do
n=$(basename "$f")
echo "$n delta $(( $(stat -c %Y "out_unzip/$n") - $(stat -c %Y "$f") )) s"
done
cam1_00.mp4 delta -1 s
cam1_01.mp4 delta 0 s
cam1_02.mp4 delta -1 s
cam1_03.mp4 delta 0 s
cam1_04.mp4 delta -1 s
cam1_05.mp4 delta 0 s
The clips on an even second come back exact. The ones on an odd second come back one second early. The ZIP central directory stores time in the old DOS format, which counts seconds in steps of two, so zipfile drops the odd second when it writes. One second does not move a clip into another day, but it does break any check that compares times for equality, such as a sync script that decides a file has changed when its time no longer matches.
Moving the zip across time zones
The DOS fields hold a date and a wall-clock time with no zone attached. The Python docs say how zipfile reads them:
"The central directory timestamp is interpreted as representing local time, rather than UTC time, to match the behavior of other zip tools."
So a zip carries the clock reading of the machine that wrote it, and the machine that reads it assumes its own zone. Here the zip is written under TZ=UTC and unpacked under New York time, once from zipfile and once from the zip command:
#!/bin/bash
# The zip is written on a machine set to UTC and opened on one set to
# New York time. Once with a Python-made zip, once with an Info-ZIP one.
rm -f clips_py.zip clips_cli.zip
TZ=UTC python3 -c '
import os, zipfile
with zipfile.ZipFile("clips_py.zip", "w") as z:
for n in sorted(os.listdir("clips")):
z.write(os.path.join("clips", n), n)'
(cd clips && TZ=UTC zip -q ../clips_cli.zip *.mp4)
for z in clips_py clips_cli; do
rm -rf out_tz && TZ=America/New_York unzip -q $z.zip -d out_tz
for n in cam1_00 cam1_01; do
echo "$z.zip $n delta $(( $(stat -c %Y out_tz/$n.mp4) - $(stat -c %Y clips/$n.mp4) )) s"
done
done
clips_py.zip cam1_00 delta 17999 s
clips_py.zip cam1_01 delta 18000 s
clips_cli.zip cam1_00 delta 0 s
clips_cli.zip cam1_01 delta 0 s
The Python-made zip came back 18000 seconds late, five hours, New York's offset from UTC in mid-November, plus the dropped odd second on the first clip. The zip made by the zip command came back exact. zipinfo shows why:
#!/bin/bash
# What each zip carries for cam1_00.mp4, besides the name.
for z in clips_py clips_cli; do
echo "== $z.zip"
zipinfo -v $z.zip cam1_00.mp4 | grep -E 'DOS date|modtime.*UTC|length of extra field'
done
== clips_py.zip
file last modified on (DOS date/time): 2025 Nov 15 13:00:00
length of extra field: 0 bytes
== clips_cli.zip
file last modified on (DOS date/time): 2025 Nov 15 13:00:02
file last modified on (UT extra field modtime): 2025 Nov 15 13:00:01 UTC
length of extra field: 24 bytes
The zip command writes an extra field with the time in UTC, and unzip prefers it. The zip from zipfile has no extra field at all, so the reader falls back to the zoneless DOS time. The docs mention this: the format can hold other timestamps, but the attribute reads one place.
"This attribute specifically returns the timestamp from the central directory."
Note also that the zip command rounded its DOS time up to the next even second while zipfile rounded down. Two tools, one clip, DOS times two seconds apart. That is from this test only; I did not look for documentation of either choice.
Putting the times back after extractall
ZipInfo.date_time still holds the stored time as a tuple, so the extraction step can set it on each file. The catch is the zone. The tuple does not say where it was written, so the caller has to know and pass it in:
# Extract with zipfile, then put each file's time back from date_time.
# date_time is wall-clock time with no zone, so the zone the zip was
# written in has to be known and passed in.
import os, sys, zipfile, shutil, calendar, time
from datetime import datetime
from zoneinfo import ZoneInfo
def extract_with_times(zpath, dest, written_in):
tz = ZoneInfo(written_in)
with zipfile.ZipFile(zpath) as z:
z.extractall(dest)
for info in z.infolist():
t = datetime(*info.date_time, tzinfo=tz).timestamp()
p = os.path.join(dest, info.filename)
os.utime(p, (t, t))
shutil.rmtree("out_fix", ignore_errors=True)
extract_with_times("clips_py.zip", "out_fix", "UTC") # tz_move.sh wrote it in UTC
for n in sorted(os.listdir("clips")):
a = os.stat(os.path.join("clips", n)).st_mtime
b = os.stat(os.path.join("out_fix", n)).st_mtime
print("%s delta %.0f s" % (n, b - a))
cam1_00.mp4 delta -1 s
cam1_01.mp4 delta 0 s
cam1_02.mp4 delta -1 s
cam1_03.mp4 delta 0 s
cam1_04.mp4 delta -1 s
cam1_05.mp4 delta 0 s
That recovers the zone and leaves the two-second steps. Run under New York time, it still matched, because the zone comes from the argument and not from the machine. If the zone is wrong, every clip is off by the difference, with no error, so this works when the same script on one known machine writes every archive.
If both ends are Python: tar keeps the time
tarfile stores modification time as seconds since the epoch, in UTC, and restores it on extract. Same clips, written under UTC and read under New York time:
# The same six clips through tarfile, written in UTC, read in New York time.
import os, tarfile, shutil, subprocess
subprocess.run(["python3", "-c",
"import tarfile; t=tarfile.open('clips.tar.gz','w:gz'); t.add('clips','clips'); t.close()"],
env=dict(os.environ, TZ="UTC"), check=True)
shutil.rmtree("out_tar", ignore_errors=True)
os.environ["TZ"] = "America/New_York"
with tarfile.open("clips.tar.gz") as t:
t.extractall("out_tar")
for n in sorted(os.listdir("clips")):
a = os.stat(os.path.join("clips", n)).st_mtime
b = os.stat(os.path.join("out_tar", "clips", n)).st_mtime
print("%s delta %.0f s" % (n, b - a))
cam1_00.mp4 delta 0 s
cam1_01.mp4 delta 0 s
cam1_02.mp4 delta 0 s
cam1_03.mp4 delta 0 s
cam1_04.mp4 delta 0 s
cam1_05.mp4 delta 0 s
Exact on every clip, odd seconds included, with no zone to pass around.
A clip from a phone with an unset clock
A file time of zero is the Unix epoch, 1 January 1970, which is what a device whose clock was not set would write. The test below sets that time on purpose. zipfile refuses it by default:
# A clip whose time was written by a device with an unset clock.
import os, zipfile
with open("clips/reset_clock.mp4", "wb") as f:
f.write(b"x")
os.utime("clips/reset_clock.mp4", (0, 0)) # 1970-01-01T00:00:00Z
try:
with zipfile.ZipFile("old.zip", "w") as z:
z.write("clips/reset_clock.mp4", "reset_clock.mp4")
except ValueError as e:
print("default:", e)
with zipfile.ZipFile("old.zip", "w", strict_timestamps=False) as z:
z.write("clips/reset_clock.mp4", "reset_clock.mp4")
print("strict_timestamps=False:", z.getinfo("reset_clock.mp4").date_time)
os.remove("clips/reset_clock.mp4")
default: ZIP does not support timestamps before 1980
strict_timestamps=False: (1980, 1, 1, 0, 0, 0)
With the default, the whole archive job stops at that file. With strict_timestamps=False it carries on, and the docs describe the cost:
"allows to zip files older than 1980-01-01 at the cost of setting the timestamp to 1980-01-01."
For a clip with a wrong clock, a wrong time is not a real loss. It does mean a job that handles old-clock clips should expect a 1980 date coming out the other side, not a 1970 one.
What I would check in an existing clip pipeline
- If any step unpacks with
zipfile, look at the modification times of a few extracted files. If they all match the time of the run, every age-based step after it is working from unpacking time. - If clips cross between machines in zips, check which tool wrote them.
zipinfo -vshows whether a UT extra field is present. - If the times have to survive exactly, keep the recording time somewhere the transport cannot touch: in the file name, or in a small manifest written next to the clips.
Things I did not test
- Newer Python versions. Everything here ran on Python 3.10.12. The quotes are from the current documentation, which may describe behavior that changed after 3.10.
-
Windows and macOS unzip tools. I ran Info-ZIP
unzipon Linux. The tool built into Windows, the Archive Utility on macOS and phone file managers each have their own handling of the DOS fields and extra fields, and none of them is checked here. - Daylight saving changes. All clips sit in mid-November, a single offset. A zip written just before a clock change and read just after adds its own edge cases.
Sources. Quotes are from the Python documentation page zipfile, Work with ZIP archives. Outputs are pasted unedited from the scripts above, run with Python 3.10.12, Zip 3.0 and UnZip 6.00.
If you want an old Android phone recording in the background so there are clips worth moving around, the app I make is Background Camera RemoteStream on Google Play. Nothing above depends on it.
A zip from zipfile keeps a zoneless wall-clock time in two-second steps and extractall() does not apply it, so set the times yourself from a known zone, or use tar.
Top comments (0)