ORM, @property y depuración de Celery:

“¿Por qué .only() dice que el campo no existe cuando claramente sí lo hace?”



Al diseñar sistemas donde la seguridad es crucial, a menudo guardamos valores de API Key o Secret en el modelo en forma cifrada y, al acceder a ellos, exponemos solo el valor descifrado mediante un @property.

Por ejemplo, la estructura suele ser así:

  • _api_key → columna real de la BD (valor cifrado)
  • api_key@property que devuelve el valor descifrado
  • api_key.setter → cifra automáticamente al guardar

Este patrón mantiene el código limpio y la seguridad natural. Sin embargo, al usarlo con el ORM (Django ORM), hay una trampa que suele pasar desapercibida.

“El modelo usa api_key sin problemas, pero el ORM dice que el campo no existe”

Este artículo explica cómo surge esa trampa, por qué la depuración es tan difícil y cómo evitarla.


1. Situación del problema: “El valor está ahí, pero el ORM dice que no”

Mientras operaba con un modelo que cifraba el campo con @property, un día el ORM ejecutó el siguiente código:

Model.objects.only("api_key").first()

o bien:

Model.objects.values("api_key")

o:

Model.objects.filter(api_key__isnull=False)

El resultado siempre fue:

FieldDoesNotExist: Model has no field named 'api_key'

Para el desarrollador, la respuesta era: “Claro que sí, lo uso todos los días con obj.api_key”.

El modelo se veía así (simplificado):

class Model(models.Model):
    _api_key = models.CharField(db_column="api_key", max_length=255, null=True)

    @property
    def api_key(self):
        if not self._api_key:
            return None
        return decrypt(self._api_key)

    @api_key.setter
    def api_key(self, value):
        self._api_key = encrypt(value)
  • En la instancia funciona con obj.api_key
  • Al guardar también funciona con obj.api_key = "plain-text"

Pero a nivel ORM, el mensaje de error es correcto: el ORM no reconoce api_key como un campo.


2. Causa: el ORM solo reconoce nombres de campos reales, no propiedades



El punto clave es simple:

api_key creado con @property no es un campo real.

El ORM llama a un campo cuando se trata de una subclase de models.Field. En otras palabras, todo lo que aparece en Model._meta.fields es lo que el ORM entiende.

En el ejemplo, el ORM solo conoce:

_api_key = models.CharField(db_column="api_key", max_length=255, null=True)
  • Nombre del campo_api_key
  • Nombre de la columna en la BD"api_key"
  • @property api_key → el ORM no lo ve

Resumen en tabla:

Nombre que usamos en el código Nombre que el ORM conoce
api_key (propiedad) _api_key (campo)

Por eso, cuando escribes:

Model.objects.only("api_key")
Model.objects.values("api_key")
Model.objects.filter(api_key__isnull=False)

el ORM responde que el campo no existe.

El ORM siempre opera sobre nombres de campo.

  • only("_api_key")
  • values("_api_key")
  • filter(_api_key__isnull=False)
  • only("api_key")
  • filter(api_key=...)

En resumen, la confusión surge al mezclar el nombre de la propiedad con el nombre real del campo.


3. ¿Por qué es más crítico dentro de Celery?

Hasta aquí parece un error “fastidioso pero solucionable”. El problema real es que el código estaba dentro de una tarea de Celery.

Supongamos algo así:

@shared_task(bind=True, max_retries=3)
def send_something(self, model_id):
    try:
        obj = Model.objects.only("api_key").get(id=model_id)
        call_external_api(obj.api_key)
    except SomeRecoverableError as exc:
        raise self.retry(exc=exc)

El verdadero problema es:

  • only("api_key") lanza FieldDoesNotExist
  • Ese error entra en la lógica de reintentos de Celery
  • Celery lo interpreta como un “error temporal” y vuelve a intentar

Así, el bug real se oculta detrás de un reintento que parece un problema de red.

Los síntomas típicos:

  • La tarea de Celery sigue reintentando
  • La API externa nunca recibe la solicitud
  • Si no revisas los logs, podrías pensar que la API está lenta o que hay un problema de red

La depuración se vuelve mucho más difícil porque el error no se lanza directamente al stack trace, sino que se “oculta” dentro del mecanismo de reintentos.


4. Solución: usar siempre el nombre real del campo en el ORM

La conclusión es simple:

Usa el nombre real del campo en el ORM; la propiedad solo se usa a nivel de instancia.

Ejemplo correcto:

# Incorrecto (causa FieldDoesNotExist)
obj = Model.objects.only("api_key").get(...)
# Correcto
obj = Model.objects.only("_api_key").get(...)
real_key = obj.api_key  # aquí ya usamos la propiedad

Lo mismo aplica al filtrado:

# Incorrecto
Model.objects.filter(api_key__isnull=False)
Model.objects.filter(api_key="plain-text")
# Correcto
Model.objects.filter(_api_key__isnull=False)
Model.objects.filter(_api_key=encrypt("plain-text"))

Para mayor seguridad, puedes establecer reglas de codificación en tu equipo:

  • “Los campos que comienzan con _ solo se usan en el ORM”
  • “Los nombres de propiedades expuestas nunca se usan en consultas ORM”
  • Revisar .values("..."), .only("..."), .filter(...) para asegurarse de que no haya nombres de propiedades.

5. Consejo adicional: usar un campo personalizado en lugar de @property

En proyectos grandes, podrías considerar crear un campo personalizado en lugar de una propiedad.

Por ejemplo, un EncryptedCharField que maneje cifrado/descifrado internamente:

class EncryptedCharField(models.CharField):
    def get_prep_value(self, value):
        if value is None:
            return None
        return encrypt(value)

    def from_db_value(self, value, expression, connection):
        if value is None:
            return None
        return decrypt(value)

class Model(models.Model):
    api_key = EncryptedCharField(max_length=255, db_column="api_key", null=True)

Con esto:

  • El ORM reconoce api_key como un campo real
  • Puedes usar only("api_key"), values("api_key"), filter(api_key__isnull=False) sin problemas
  • El código sigue siendo limpio: obj.api_key devuelve el valor descifrado

Desventajas:

  • Necesitas implementar y probar el campo personalizado
  • Consideraciones de migraciones y compatibilidad

Elige la opción que mejor se adapte a tu equipo y proyecto.


6. Lección aprendida: lo simple puede ser fácil de pasar por alto

La moraleja es sencilla pero importante:

  • Los nombres de las propiedades pueden confundir al ORM
  • En entornos asíncronos como Celery, los errores se ocultan detrás de reintentos
  • Revisar cuidadosamente las consultas ORM y establecer reglas claras ayuda a evitar este tipo de bugs

7. Resumen

1) El ORM solo entiende nombres de campos reales

  • Model._meta.fields es la referencia
  • @property no está incluido

2) No usar @property en consultas ORM

  • only("api_key"), filter(api_key=...) son incorrectos
  • Usa siempre el nombre real, por ejemplo _api_key

3) La seguridad y la claridad de nombres

  • Evita nombres similares que puedan confundir
  • Revisa las consultas en el código y en los PRs

4) En Celery, los errores pueden convertirse en reintentos silenciosos

  • Asegúrate de que los errores críticos no se traten como “temporal”
  • Revisa la política de manejo de excepciones y los logs

Con estos puntos en mente, podrás evitar perder días de trabajo por un error que parece un problema de red.

image