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

Lessons Learned from Our Migration from Qwen 3.6 to 3.8

Johannes Rest


.NET Architekt und Entwickler


Beitragsnavigation


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert