Deriving Pet Life Stages From Date of Birth Without a Cron Job

Deriving Pet Life Stages From Date of Birth Without a Cron Job

A dog's life stage should change as it gets older. But storing a value like "Adult" in a database does not make it change automatically. We solved this by storing the dog's date of birth and deriving its life stage whenever the profile is saved or read. The server also updates the stored value when it notices a change, so there is no nightly cron job, database migration, or manual update needed.

This post explains why storing an age category can cause problems, why a scheduled job was not the right solution, and how we use a small date-based function to keep pet profiles up to date. We will also look at how the server handles older records, failed writes, leap-day birthdays, and time zones.

The short version

  • A dog's profile has 5 life stages, which means its stored stage needs to change only 4 times during its life.
  • The server derives the life stage from the date of birth on every save and read, so the API always returns the stage appropriate for the current date.
  • When a read finds that the stored stage is outdated, it updates the database only if the value has changed.
  • Unknown life-stage values are rejected with a 422 error during writes, but older or unexpected values never cause a read to fail.

The problem with storing a dog's age as a fixed value

During onboarding, we ask pet parents to select an approximate age range for their dog. For example, they might choose "Senior, 10+ years" instead of entering an exact date of birth.

There is a practical reason for this. Many people adopt dogs and may not know their exact birthday. An approximate age range gives us enough information to start personalizing the experience without forcing them to guess a date.

The problem starts when an owner later adds the dog's actual date of birth.

The selected age range and the birthday might not agree. Someone could initially select "Adult" and later enter a birthday that puts their dog in a different life stage. We also had another problem: once a stage was saved, nothing automatically changed it as the dog grew older.

A dog could be saved as "Adult" and continue showing that same value years later.

We needed the profile to reflect the dog's current life stage without asking the owner to update it manually.

Why the obvious solutions were not enough

We considered a few approaches before settling on the final implementation.

Storing the life stage as a regular database field

This was essentially what we already had, and it was the source of the problem.

A value saved once does not automatically become outdated in the database when time passes. If a dog was marked as an adult at 5, the database would still say "Adult" at 9 unless something explicitly updated it.

The stored value was correct when it was first written, but that did not make it permanently correct.

Running a nightly job

Another option was to create a scheduled job that checks every pet once a day and recalculates its life stage.

That would work, but it seemed like too much machinery for such a small change. A dog's life stage changes only a handful of times, yet a nightly job would revisit the same record every day.

It would also need to read every pet record. Our database access rules do not allow full-table scans, so building this approach would introduce additional complexity.

There is also the operational side of it. A scheduled job becomes another thing to deploy, monitor, and troubleshoot. If it silently stops running, profiles can start showing outdated information again.

Calculating the stage without updating the database

We also considered calculating the life stage whenever someone reads a pet profile.

This solves the immediate problem because the API can return the correct value even when the database contains an older one.

However, the stored field would remain outdated. Any other process reading the database directly, such as a report or export, could still get the old value.

We wanted both a correct API response and a database that gradually corrects itself. That led us to combine calculation on read with a small conditional update.

Store the birthday, derive the life stage

The idea behind the implementation is straightforward: store the information that does not change and calculate the information that does.

A dog's date of birth remains the same. Its life stage depends on that date and the current date. Storing the life stage as a permanent fact means we have to keep updating it. Storing the birthday means we can always calculate the correct stage when we need it.

We keep the calculation in one function:

from datetime import date

UPPER_YEARS = [
    ("puppy", 1),
    ("adolescent", 3),
    ("adult", 6),
    ("mature", 10)
]

def age_group_for_birthday(birthday: str, today: date) -> str | None:
    if not birthday:
        return None

    try:
        born = date.fromisoformat(birthday[:10])
    except ValueError:
        return None

    years = today.year - born.year

    if (today.month, today.day) < (born.month, born.day):
        years -= 1

    for group, upper in UPPER_YEARS:
        if years < upper:
            return group

    return "senior"

How the calculation works

The function calculates the difference between the current year and the birth year. But that alone is not enough.

For example, if a dog was born in December and we are currently in October, subtracting the years would make the dog appear older than it actually is. So we compare the current month and day with the birthday. If the birthday has not happened yet this year, we subtract one.

The function then checks the dog's age against the life-stage boundaries. Each stage includes its lower age and excludes its upper age. This means a dog moves into the next stage on its birthday, rather than several months early.

What happens when the birthday is missing?

Not every pet has a known date of birth. Some owners only provide an approximate age range, and we do not want to replace that information with an empty value.

The function returns None when the birthday is missing or cannot be parsed. That tells the rest of the application to retain the owner's selected age range instead of trying to calculate something we do not know.

This allows approximate age information and an actual birthday to coexist without one unnecessarily overwriting the other.

Strict on write: the birthday takes priority

Whenever a pet profile is saved, the server processes the incoming fields before writing them to the database.

There are three important rules around the age information:

  1. If the request includes a birthday, the server calculates the life stage itself. Even if the application sends a different stage, the birthday takes priority.
  2. If the birthday is removed, the owner's previously selected age range is retained.
  3. If the application sends a life-stage value that the server does not recognize, the request is rejected with a 422 response that lists the allowed values.

This keeps the database from receiving conflicting or invalid information.

The important part is that the server owns this decision. The application can suggest a value, but it cannot override the rule that determines what belongs in the database.

We follow the same principle in our work on checking before the write, where the server is responsible for enforcing the conditions that make a record valid.

Lenient on read: always calculate for today

The read path handles things a little differently.

When the server converts a database row into a pet profile, it does not blindly trust the stored life stage. It checks the birthday and calculates the value again using the current date.

The behavior is simple:

  • If the pet has a birthday, calculate the life stage from that birthday.
  • If there is no birthday, use the age range already stored in the profile.
  • If the stored value is something the current server version does not recognize, log a warning and treat it as "not stated" instead of failing the entire request.

This distinction matters when the application and database have been around for a while. Old records may contain values that newer versions of the application no longer expect.

Rejecting those records during a read would mean a single unexpected field could prevent someone from opening a pet profile.

So we take a different approach: validate strictly when data enters the system, but remain tolerant when reading existing data.

Let reads repair the stored value

Calculating the correct life stage solves the API response problem, but we still need to deal with the outdated database value.

We handle that with a conditional write-back.

After calculating the life stage, the server compares it with the value already stored in the database. If both values match, nothing happens. If they differ, the server updates the stored field.

The logic is small:

def store_if_aged(row, derived_stage):
    if not row.get("birthday") or row.get("age_group") == derived_stage:
        return

    try:
        save_field(row["pet_id"], "age_group", derived_stage)
    except Exception:
        log.warning("age group refresh failed")

There are a few reasons this approach works well.

It writes only when something changes. A dog's life stage changes 4 times over its life. We are not updating the database on every read, only when the calculated value differs from the stored one.

A failed write does not break the response. The API has already calculated the correct life stage. If the database update fails, we log a warning and let a later read try again.

Concurrent reads are safe. Two requests might notice the same outdated value at almost the same time. Both may attempt to write the new stage, but they are writing the same value. Repeating the operation does not change the final result.

This is similar to the idempotent behavior we discussed in a lost response is not a lost request. The important idea is that repeating the same operation should not introduce a different result.

Our pet profiles are read regularly, both when owners open the app and when daily insights are generated. That gives the database frequent opportunities to correct an outdated stage. For pets that are read daily, the stored value catches up within a day of the birthday.

This pattern is related to read repair, where a read operation also helps correct outdated data rather than leaving the responsibility entirely to a separate background process.

Nightly job vs. deriving on read

Here is how the two approaches compare.

DetailNightly jobDerive on read and write back
Checks per pet per year365Calculated during reads that already happen
Writes during a pet's lifeDepends on the job's design4
API response on the birthdayMay remain outdated until the job runsCorrect on the first read
Requires a full-table readYesNo
Additional infrastructureScheduler and background jobNo additional scheduler
Stored value freshnessDepends on the job runningUpdated when a pet is read

A nightly job has one advantage: it can update profiles even when nobody opens them.

Our approach does not guarantee that. If a pet profile is never read, its stored value can remain outdated. However, whenever the profile is read, the API still calculates and returns the correct life stage.

For our use case, that was a reasonable trade-off. We did not need another scheduled process just to keep a derived field synchronized.

Edge cases we had to consider

The main calculation is simple, but dates always come with a few details worth thinking about.

What about leap-day birthdays?

A dog born on February 29 does not have an exact birthday in most years.

Our current comparison treats February 28 as a day before the birthday. So, in a non-leap year, the dog moves into the next life stage on March 1.

We chose this behavior rather than introducing a separate set of special cases into the calculation.

What about time zones?

The current date used by the server is based on UTC.

For an owner in India, which is 5.5 hours ahead of UTC, a dog may move into its next life stage up to 5.5 hours after local midnight on its birthday.

For a life-stage category, we accepted that small difference. The calculation does not need to be accurate to the minute.

What about older profiles without a birthday?

Some early profiles contain a manually entered age in years but do not have a birthday or a selected age range.

The application handles these records by mapping the entered number to a life stage on the client side. It is a fallback for older data, not the source of truth. Once the owner adds a birthday, the server-derived value takes over.

What this lets EverBowl do

EverBowl™ learns a dog's mealtime routine and builds a record of it over time. That record becomes more useful when it sits alongside a profile that reflects the dog's current life stage, rather than an age category selected years ago during onboarding.

There is an important limit here. A life stage is only a category derived from a date of birth. It does not tell us whether a dog is healthy, and when the birthday is unknown, the category remains an estimate based on what the owner provides.

EverBowl does not diagnose; the value is the record.

Conclusion

When a value can be calculated from information that does not change, it is worth asking whether that value needs to be stored as a permanent fact. In our case, storing the birthday and deriving the life stage gave us a simpler way to keep profiles accurate without introducing another scheduled job.

We calculate the value when data is saved and when it is read, reject invalid input at the write boundary, and tolerate older data during reads. When the stored value is outdated, we update it only when necessary.

Before introducing a cron job to maintain a derived field, it is worth checking whether the reads your application already performs can do that work instead.

FAQs

Why not store the dog's age in years?

Age in years changes every birthday, so storing it as a fixed value creates the same problem as storing a life stage. The date of birth is the underlying information that remains unchanged, making it a better source for calculating age.

Does updating the database during a read make the API slower?

The additional write happens only when the calculated life stage differs from the stored value. Since a dog moves between life stages only a handful of times, most reads perform the calculation and comparison without writing anything.

What happens if the owner selects an age range that conflicts with the birthday?

The birthday takes priority whenever it is available. During a save, the server calculates the life stage from the birthday and replaces any conflicting value sent by the application. The manually selected range is used when there is no birthday.

What happens if a new life stage is added later?

An older server might encounter a value it does not recognize. Instead of failing the entire read, our implementation logs a warning and treats the value as not stated. This allows the data and application versions to evolve without making older records inaccessible.

Is this the same as using a database computed column?

The idea is similar because both approaches derive a value from other stored information. The difference is that our calculation also depends on the current date, which changes over time. We therefore calculate it in application code whenever the pet profile is read.

References