Version upgrades often look deceptively simple on paper. Moving from 3.6 to 3.8 sounds like a minor step, maybe even routine maintenance. In practice, it became a useful reminder that even small platform upgrades deserve proper planning, clean execution, and a reliable rollback strategy.
Our migration from 3.6 to 3.8 was not just about changing a version number. It was about validating assumptions, tightening operational discipline, and making sure the surrounding ecosystem was ready for the new baseline.
Why We Migrated
The main driver was simple: staying current.
Running on an older version always creates a growing gap between the system and the current ecosystem. Dependencies move on, tooling evolves, security expectations increase, and knowledge around older versions slowly fades. The longer an upgrade is postponed, the more expensive and risky it becomes.
Moving to 3.8 gave us a better-supported foundation, improved compatibility with newer dependencies, and a cleaner path for future upgrades. Just as important, it forced us to review areas that had quietly accumulated technical debt.
Preparation Was the Most Important Part
The actual upgrade was only one part of the work. Most of the value came from preparation.
Before touching production, we reviewed the existing environment, documented the current state, checked dependencies, and identified components that were likely to be affected by the version change. This included application code, configuration, deployment scripts, scheduled jobs, external integrations, and operational tooling.
A simple inventory script helped us capture the starting point:
#!/usr/bin/env bash
set -euo pipefail
echo "== Runtime =="
python3 --version || true
echo
echo "== Installed packages =="
python3 -m pip freeze | sort > requirements-current.txt
cat requirements-current.txt
echo
echo "== System services =="
systemctl list-units --type=service --state=running | grep -E 'app|worker|scheduler' || true
echo
echo "== Disk usage =="
df -h
echo
echo "== Application directory =="
find /opt/my-application -maxdepth 2 -type f | sort
The goal was not to create perfect documentation. The goal was to know what we were actually running before we changed it.
One of the key lessons was that migration planning should not focus only on the application itself. The surrounding runtime environment matters just as much. A version upgrade can expose hidden assumptions in startup scripts, file permissions, logging configuration, monitoring, backup routines, and deployment automation.
Dependency Compatibility Checks Saved Time
The upgrade path from 3.6 to 3.8 was mostly straightforward, but not completely frictionless. Some dependencies behaved differently, and a few assumptions that had worked for a long time needed to be revisited.
Before changing production, we created a clean environment and tested dependency resolution there:
python3.8 -m venv .venv-3.8
source .venv-3.8/bin/activate
python -m pip install --upgrade pip setuptools wheel
python -m pip install -r requirements.txt
python -m pip check
python -m pip freeze | sort > requirements-3.8-resolved.txt
This gave us two useful artifacts:
requirements-current.txt
requirements-3.8-resolved.txt
Comparing both files made dependency drift visible:
diff -u requirements-current.txt requirements-3.8-resolved.txt || true
That comparison was valuable because it separated expected changes from accidental upgrades. We did not want to debug application behavior while also wondering whether a transitive dependency had silently changed.
For critical dependencies, we pinned versions explicitly:
requests==2.31.0
SQLAlchemy==1.4.52
psycopg2-binary==2.9.9
gunicorn==21.2.0
The lesson was simple: during a runtime migration, uncontrolled dependency upgrades create unnecessary risk.
Code Changes Were Smaller Than Expected
The application code itself required fewer changes than expected, but the changes that did appear were important.
One recurring topic was compatibility with language and library behavior. For example, older code often had assumptions around dictionary ordering, encoding, or implicit conversions. Some of these assumptions had never been documented because they had simply “always worked”.
A typical cleanup looked like this:
# Before
def build_payload(user):
return {
"id": user.id,
"name": user.name.encode("utf-8"),
"email": user.email,
}
The newer runtime made it obvious that we should be explicit about the boundary between text and bytes:
# After
def build_payload(user):
return {
"id": user.id,
"name": user.name,
"email": user.email,
}
Encoding now happens only at the I/O boundary:
import json
def serialize_payload(payload):
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
This made the code easier to reason about and reduced hidden behavior.
Configuration Needed Special Attention
Configuration was one of the areas where small differences mattered.
We reviewed environment variables, file paths, service definitions, and runtime flags. In particular, we made sure that configuration was loaded explicitly and failed fast when required values were missing.
import os
class ConfigError(RuntimeError):
pass
def require_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise ConfigError(f"Required environment variable is missing: {name}")
return value
DATABASE_URL = require_env("DATABASE_URL")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
This is not fancy code, but it is useful operational code. During migrations, clear failures are much better than vague runtime behavior.
We also checked service configuration. A minimal systemd unit looked like this:
[Unit]
Description=My Application
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/my-application
EnvironmentFile=/etc/my-application/app.env
ExecStart=/opt/my-application/.venv/bin/gunicorn app.main:application --bind 127.0.0.1:8000
Restart=on-failure
RestartSec=5
TimeoutStartSec=60
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
The important part was not the exact unit file. The important part was that runtime, user, working directory, environment file, and startup command were explicit.
Testing Needed to Cover More Than Happy Paths
A basic smoke test is useful, but it is not enough for a migration like this.
We tested the obvious things first: application startup, core workflows, authentication, background jobs, API endpoints, and integrations. But the more valuable tests were the less glamorous ones: restart behavior, logging, error handling, backup and restore, scheduled processes, and rollback readiness.
Our smoke test script stayed intentionally boring:
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
echo "Checking health endpoint..."
curl -fsS "$BASE_URL/health"
echo
echo "Checking readiness endpoint..."
curl -fsS "$BASE_URL/ready"
echo
echo "Checking API version..."
curl -fsS "$BASE_URL/api/version"
echo
echo "Smoke test completed successfully."
For application-level tests, we kept the checks focused on behavior:
def test_health_endpoint(client):
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_version_endpoint(client):
response = client.get("/api/version")
assert response.status_code == 200
assert "version" in response.json()
The goal was not only to confirm that the system worked after the migration. The goal was also to confirm that we could operate it safely afterwards.
A migration is only successful when the system is stable, observable, recoverable, and understandable in its new state.
Backups and Rollback Were Non-Negotiable
Before making changes, we created backups and verified that they were usable. This sounds obvious, but it is one of the most important parts of any migration.
A backup that has not been tested is only a hope.
Our backup process captured data and configuration separately:
#!/usr/bin/env bash
set -euo pipefail
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="/var/backups/my-application"
APP_DIR="/opt/my-application"
mkdir -p "$BACKUP_DIR"
tar -czf "$BACKUP_DIR/app-config-$STAMP.tar.gz" \
/etc/my-application \
"$APP_DIR"/deployment \
"$APP_DIR"/requirements.txt
pg_dump "$DATABASE_URL" | gzip > "$BACKUP_DIR/database-$STAMP.sql.gz"
sha256sum "$BACKUP_DIR/app-config-$STAMP.tar.gz" > "$BACKUP_DIR/app-config-$STAMP.tar.gz.sha256"
sha256sum "$BACKUP_DIR/database-$STAMP.sql.gz" > "$BACKUP_DIR/database-$STAMP.sql.gz.sha256"
echo "Backup completed: $STAMP"
We also tested backup integrity:
gzip -t /var/backups/my-application/database-YYYYMMDD-HHMMSS.sql.gz
sha256sum -c /var/backups/my-application/database-YYYYMMDD-HHMMSS.sql.gz.sha256
The rollback plan was written down before the migration started:
#!/usr/bin/env bash
set -euo pipefail
echo "Stopping application..."
systemctl stop my-application.service
echo "Restoring previous release..."
ln -sfn /opt/releases/my-application-previous /opt/my-application/current
echo "Restoring dependencies..."
cd /opt/my-application/current
python3.6 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
echo "Starting application..."
systemctl start my-application.service
echo "Checking health..."
curl -fsS http://127.0.0.1:8000/health
echo "Rollback completed."
The important lesson here is simple: rollback planning is not pessimism. It is professional risk management.
Observability Made the Difference
Logs, health checks, and service status information were essential during the migration.
Instead of relying on vague impressions, we used concrete signals: service health, startup behavior, error logs, response checks, and operational status.
A small logging cleanup made troubleshooting easier:
import logging
import os
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=LOG_LEVEL,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger(__name__)
def start_application():
logger.info("Starting application")
logger.info("Runtime version check completed")
During the migration, these commands were used constantly:
systemctl status my-application.service --no-pager
journalctl -u my-application.service -n 150 --no-pager
curl -fsS http://127.0.0.1:8000/health && echo
Good observability also helped after the migration. The first minutes and hours after an upgrade are important. Clear health checks and useful logs allowed us to verify that the system remained stable after the initial deployment.
Deployment Became More Repeatable
One of the best outcomes of the migration was a more repeatable deployment process.
Instead of applying manual changes directly, we moved toward a predictable sequence:
#!/usr/bin/env bash
set -euo pipefail
RELEASE_DIR="/opt/releases/my-application-$(date +%Y%m%d-%H%M%S)"
CURRENT_LINK="/opt/my-application/current"
echo "Creating release directory..."
mkdir -p "$RELEASE_DIR"
echo "Copying application..."
rsync -a --delete ./ "$RELEASE_DIR/"
echo "Creating virtual environment..."
python3.8 -m venv "$RELEASE_DIR/.venv"
"$RELEASE_DIR/.venv/bin/python" -m pip install --upgrade pip
"$RELEASE_DIR/.venv/bin/python" -m pip install -r "$RELEASE_DIR/requirements.txt"
echo "Running tests..."
"$RELEASE_DIR/.venv/bin/python" -m pytest "$RELEASE_DIR/tests"
echo "Switching release..."
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
echo "Restarting service..."
systemctl restart my-application.service
echo "Checking health..."
curl -fsS http://127.0.0.1:8000/health
echo "Deployment completed."
This pattern reduced risk because every step was visible. If something failed, we knew where it failed.
Small Differences Matter
One of the more practical lessons was that small environmental differences can cause disproportionate problems.
Path handling, permissions, service users, startup behavior, and configuration loading can all behave slightly differently across environments. These issues are rarely exciting, but they are often the reason migrations take longer than expected.
For example, this kind of check helped us catch permission issues early:
sudo -u myapp test -r /etc/my-application/app.env && echo "env readable"
sudo -u myapp test -x /opt/my-application/current/.venv/bin/python && echo "runtime executable"
sudo -u myapp /opt/my-application/current/.venv/bin/python --version
The solution is not to avoid change. The solution is to make the environment explicit. Configuration should be documented, service users should be clear, file permissions should be intentional, and deployment steps should be repeatable.
The more repeatable the process, the less stressful the migration becomes.
What Went Well
Several things worked well during the migration.
We had a clear target state. We knew what “done” should look like before we started.
We validated the system step by step instead of changing everything at once.
We kept backups and rollback options available.
We used health checks and logs to verify the result.
We documented the process while working through it, which will make future upgrades easier.
Most importantly, we treated the migration as an operational change, not just a development task.
What We Would Do Again
For future migrations, we would follow the same basic approach:
1. Inventory the current system.
2. Freeze and document dependencies.
3. Build a clean target runtime.
4. Run compatibility checks.
5. Execute automated tests.
6. Create and verify backups.
7. Define rollback before production changes.
8. Deploy in a controlled sequence.
9. Validate with health checks and logs.
10. Document the final state.
This approach may feel slower at the beginning, but it saves time when it matters most.
What We Would Improve
There is always room to improve.
For future upgrades, we would invest even more in automation around validation. Manual checks are useful, but automated health checks, repeatable deployment scripts, and automated backup verification reduce risk even further.
We would also aim to make environment differences more visible earlier. The fewer hidden assumptions remain, the smoother the next migration will be.
Finally, we would continue improving documentation during the process, not afterwards. Notes written during the actual migration capture details that are easy to forget later.
Résumé
Our migration from 3.6 to 3.8 confirmed something we already knew but appreciated seeing again in practice: successful upgrades are not defined by the version number. They are defined by preparation, validation, observability, and the ability to recover.
The technical change itself was manageable. The real work was making sure the system, the environment, and the operational processes around it were ready.
The migration gave us more than a newer runtime. It gave us a cleaner baseline, better confidence in our setup, and a more reliable foundation for future changes.
That is the real value of a well-executed migration: not just reaching the next version, but improving the way you operate the system along the way.
Views: 0
