Repository: dbt-labs/dbt-core

Issue:
move `loaded_at_field` and `loaded_at_query` under config
### Housekeeping

- [x] I am a maintainer of dbt-core

### Short description

We're moving `freshness` under config -> https://github.com/dbt-labs/dbt-core/issues/11506

We should do the same for `loaded_at_field` and `loaded_at_query`.

### Acceptance criteria

- move `loaded_at_query` and `loaded_at_field` to be configs
- issue deprecation warning for setting `loaded_at_field` as a property (`loaded_at_query` has not yet been released in core final version, so we shouldn’t need a deprecation warning for it? unless cloud folks are using it on `latest` release track)



Discussion:

Patch:
diff --git a/.changes/unreleased/Features-20250623-113130.yaml b/.changes/unreleased/Features-20250623-113130.yaml
new file mode 100644
index 00000000000..e8470e50aaf
--- /dev/null
+++ b/.changes/unreleased/Features-20250623-113130.yaml
@@ -0,0 +1,6 @@
+kind: Features
+body: Support loaded_at_query and loaded_at_field on source and table configs
+time: 2025-06-23T11:31:30.897805-04:00
+custom:
+  Author: michelleark
+  Issue: "11659"
diff --git a/core/dbt/artifacts/resources/v1/source_definition.py b/core/dbt/artifacts/resources/v1/source_definition.py
index ad566c24854..12a4f1c1867 100644
--- a/core/dbt/artifacts/resources/v1/source_definition.py
+++ b/core/dbt/artifacts/resources/v1/source_definition.py
@@ -21,6 +21,8 @@ class SourceConfig(BaseConfig):
     enabled: bool = True
     event_time: Any = None
     freshness: Optional[FreshnessThreshold] = field(default_factory=FreshnessThreshold)
+    loaded_at_field: Optional[str] = None
+    loaded_at_query: Optional[str] = None
 
 
 @dataclass
diff --git a/core/dbt/parser/schemas.py b/core/dbt/parser/schemas.py
index fd089a2ebb5..ae54c8d8091 100644
--- a/core/dbt/parser/schemas.py
+++ b/core/dbt/parser/schemas.py
@@ -157,12 +157,16 @@ def yaml_from_file(
                 f"Contents of file '{source_file.original_file_path}' are not valid. Dictionary expected."
             )
 
-        # When loaded_loaded_at_field is defined as None or null, it shows up in
+        # When loaded_at_field is defined as None or null, it shows up in
         # the dict but when it is not defined, it does not show up in the dict
         # We need to capture this to be able to override source level settings later.
         for source in contents.get("sources", []):
             for table in source.get("tables", []):
-                if "loaded_at_field" in table:
+                if "loaded_at_field" in table or (
+                    "config" in table
+                    and table["config"] is not None
+                    and table["config"].get("loaded_at_field")
+                ):
                     table["loaded_at_field_present"] = True
 
         return contents
diff --git a/core/dbt/parser/sources.py b/core/dbt/parser/sources.py
index ffcbdb78d96..467d32e5f2d 100644
--- a/core/dbt/parser/sources.py
+++ b/core/dbt/parser/sources.py
@@ -1,7 +1,7 @@
 import itertools
 from dataclasses import replace
 from pathlib import Path
-from typing import Any, Dict, Iterable, List, Optional, Set
+from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
 
 from dbt import deprecations
 from dbt.adapters.capability import Capability
@@ -131,32 +131,6 @@ def parse_source(self, target: UnpatchedSourceDefinition) -> SourceDefinition:
         description = table.description or ""
         source_description = source.description or ""
 
-        # We need to be able to tell the difference between explicitly setting the loaded_at_field to None/null
-        # and when it's simply not set.  This allows a user to override the source level loaded_at_field so that
-        # specific table can default to metadata-based freshness.
-        if table.loaded_at_field_present and table.loaded_at_query:
-            raise ParsingError(
-                "Cannot specify both loaded_at_field and loaded_at_query at table level."
-            )
-        if source.loaded_at_field and source.loaded_at_query:
-            raise ParsingError(
-                "Cannot specify both loaded_at_field and loaded_at_query at source level."
-            )
-
-        if table.loaded_at_field_present or table.loaded_at_field is not None:
-            loaded_at_field = table.loaded_at_field
-        else:
-            loaded_at_field = source.loaded_at_field  # may be None, that's okay
-
-        loaded_at_query: Optional[str]
-        if table.loaded_at_query is not None:
-            loaded_at_query = table.loaded_at_query
-        else:
-            if table.loaded_at_field_present:
-                loaded_at_query = None
-            else:
-                loaded_at_query = source.loaded_at_query
-
         quoting = source.quoting.merged(table.quoting)
         # path = block.path.original_file_path
         table_meta = table.meta or {}
@@ -204,8 +178,8 @@ def parse_source(self, target: UnpatchedSourceDefinition) -> SourceDefinition:
             source_meta=source_meta,
             meta=meta,
             loader=source.loader,
-            loaded_at_field=loaded_at_field,
-            loaded_at_query=loaded_at_query,
+            loaded_at_field=config.loaded_at_field,
+            loaded_at_query=config.loaded_at_query,
             freshness=config.freshness,
             quoting=quoting,
             resource_type=NodeType.Source,
@@ -333,6 +307,12 @@ def _generate_source_config(self, target: UnpatchedSourceDefinition, rendered: b
             # there should be no freshness precedence
             precedence_configs.pop("freshness", None)
 
+        precedence_loaded_at_field, precedence_loaded_at_query = (
+            self.calculate_loaded_at_field_query_from_raw_target(target)
+        )
+        precedence_configs["loaded_at_field"] = precedence_loaded_at_field
+        precedence_configs["loaded_at_query"] = precedence_loaded_at_query
+
         # Because freshness is a "object" config, the freshness from the dbt_project.yml and the freshness
         # from the schema file _won't_ get merged by this process. The result will be that the freshness will
         # come from the schema file if provided, and if not, it'll fall back to the dbt_project.yml freshness.
@@ -440,6 +420,58 @@ def calculate_freshness_from_raw_target(
             table_config_freshness,
         )
 
+    def calculate_loaded_at_field_query_from_raw_target(
+        self, target: UnpatchedSourceDefinition
+    ) -> Tuple[Optional[str], Optional[str]]:
+        # We need to be able to tell the difference between explicitly setting the loaded_at_field to None/null
+        # and when it's simply not set.  This allows a user to override the source level loaded_at_field so that
+        # specific table can default to metadata-based freshness.
+
+        # loaded_at_field and loaded_at_query are supported both at top-level (deprecated) and config-level (preferred) on sources and tables.
+        if target.table.loaded_at_field_present and (
+            target.table.loaded_at_query or target.table.config.get("loaded_at_query")
+        ):
+            raise ParsingError(
+                "Cannot specify both loaded_at_field and loaded_at_query at table level."
+            )
+        if (target.source.loaded_at_field or target.source.config.get("loaded_at_field")) and (
+            target.source.loaded_at_query or target.source.config.get("loaded_at_query")
+        ):
+            raise ParsingError(
+                "Cannot specify both loaded_at_field and loaded_at_query at source level."
+            )
+
+        if (
+            target.table.loaded_at_field_present
+            or target.table.loaded_at_field is not None
+            or target.table.config.get("loaded_at_field") is not None
+        ):
+            loaded_at_field = target.table.loaded_at_field or target.table.config.get(
+                "loaded_at_field"
+            )
+        else:
+            loaded_at_field = target.source.loaded_at_field or target.source.config.get(
+                "loaded_at_field"
+            )  # may be None, that's okay
+
+        loaded_at_query: Optional[str]
+        if (
+            target.table.loaded_at_query is not None
+            or target.table.config.get("loaded_at_query") is not None
+        ):
+            loaded_at_query = target.table.loaded_at_query or target.table.config.get(
+                "loaded_at_query"
+            )
+        else:
+            if target.table.loaded_at_field_present:
+                loaded_at_query = None
+            else:
+                loaded_at_query = target.source.loaded_at_query or target.source.config.get(
+                    "loaded_at_query"
+                )
+
+        return loaded_at_field, loaded_at_query
+
 
 def merge_freshness_time_thresholds(
     base: Optional[Time], update: Optional[Time]


Test patch:
diff --git a/tests/functional/artifacts/expected_manifest.py b/tests/functional/artifacts/expected_manifest.py
index 6705ba88efc..f9c88c14ec6 100644
--- a/tests/functional/artifacts/expected_manifest.py
+++ b/tests/functional/artifacts/expected_manifest.py
@@ -821,6 +821,8 @@ def expected_seeded_manifest(project, model_database=None, quote_model=False):
                         "warn_after": {"count": None, "period": None},
                         "filter": None,
                     },
+                    "loaded_at_query": None,
+                    "loaded_at_field": None,
                 },
                 "quoting": {
                     "database": None,
@@ -857,7 +859,7 @@ def expected_seeded_manifest(project, model_database=None, quote_model=False):
                 "tags": [],
                 "unique_id": "source.test.my_source.my_table",
                 "fqn": ["test", "my_source", "my_table"],
-                "unrendered_config": {},
+                "unrendered_config": {"loaded_at_field": None, "loaded_at_query": None},
                 "unrendered_database": None,
                 "unrendered_schema": "{{ var('test_schema') }}",
                 "doc_blocks": [],
@@ -1362,6 +1364,8 @@ def expected_references_manifest(project):
                         "warn_after": {"count": None, "period": None},
                         "filter": None,
                     },
+                    "loaded_at_field": None,
+                    "loaded_at_query": None,
                 },
                 "quoting": {
                     "database": False,
@@ -1397,7 +1401,7 @@ def expected_references_manifest(project):
                 "tags": [],
                 "unique_id": "source.test.my_source.my_table",
                 "fqn": ["test", "my_source", "my_table"],
-                "unrendered_config": {},
+                "unrendered_config": {"loaded_at_field": None, "loaded_at_query": None},
                 "unrendered_database": None,
                 "unrendered_schema": "{{ var('test_schema') }}",
                 "doc_blocks": ["doc.test.table_info"],
diff --git a/tests/functional/list/test_list.py b/tests/functional/list/test_list.py
index 80d4a2f7bd7..6dff0eb7249 100644
--- a/tests/functional/list/test_list.py
+++ b/tests/functional/list/test_list.py
@@ -644,6 +644,8 @@ def expect_source_output(self):
                     },
                     "meta": {"test": 1},
                     "tags": ["tag"],
+                    "loaded_at_query": None,
+                    "loaded_at_field": "column_name",
                 },
                 "unique_id": "source.test.my_source.my_table",
                 "original_file_path": normalize("models/schema.yml"),
diff --git a/tests/functional/sources/fixtures.py b/tests/functional/sources/fixtures.py
index e2545765224..01c909927e4 100644
--- a/tests/functional/sources/fixtures.py
+++ b/tests/functional/sources/fixtures.py
@@ -534,3 +534,93 @@
       - name: source_a
         loaded_at_field: "{{ var('test_loaded_at') | as_text }}"
 """
+
+source_config_loaded_at_query_config_level = """
+sources:
+  - name: test_source
+    config:
+      loaded_at_query: 'select 1'
+    tables:
+      - name: test_table
+"""
+
+source_config_loaded_at_field_config_level = """
+sources:
+  - name: test_source
+    config:
+      loaded_at_field: 'id'
+    tables:
+      - name: test_table
+"""
+
+source_table_config_loaded_at_field_config_level = """
+sources:
+  - name: test_source
+    tables:
+      - name: test_table
+        config:
+          loaded_at_field: 'id'
+"""
+
+source_table_config_loaded_at_query_config_level = """
+sources:
+  - name: test_source
+    tables:
+      - name: test_table
+        config:
+          loaded_at_query: 'select 1'
+"""
+
+source_table_config_loaded_at_query_not_set_if_field_present = """
+sources:
+  - name: test_source
+    config:
+      loaded_at_query: 'select 1'
+    tables:
+      - name: test_table
+        config:
+          loaded_at_field: 'id'
+"""
+
+# Legacy top-level support
+source_config_loaded_at_field_top_level = """
+sources:
+  - name: test_source
+    loaded_at_field: 'id'
+    tables:
+      - name: test_table
+"""
+
+source_config_loaded_at_query_top_level = """
+sources:
+  - name: test_source
+    loaded_at_query: 'select 1'
+    tables:
+      - name: test_table
+"""
+
+table_config_loaded_at_field_top_level = """
+sources:
+  - name: test_source
+    tables:
+      - name: test_table
+        loaded_at_field: 'id'
+"""
+
+table_config_loaded_at_query_top_level = """
+sources:
+  - name: test_source
+    loaded_at_query: 'select 1'
+    tables:
+      - name: test_table
+        loaded_at_query: 'select 1'
+"""
+
+source_table_config_loaded_at_query_not_set_if_field_present_top_level = """
+sources:
+  - name: test_source
+    loaded_at_query: 'select 1'
+    tables:
+      - name: test_table
+        loaded_at_field: 'id'
+"""
diff --git a/tests/functional/sources/test_source_configs.py b/tests/functional/sources/test_source_configs.py
index 1ceca5d0522..0ee9303f820 100644
--- a/tests/functional/sources/test_source_configs.py
+++ b/tests/functional/sources/test_source_configs.py
@@ -11,6 +11,16 @@
     disabled_source_level_schema_yml,
     disabled_source_table_schema_yml,
     invalid_config_source_schema_yml,
+    source_config_loaded_at_field_config_level,
+    source_config_loaded_at_field_top_level,
+    source_config_loaded_at_query_config_level,
+    source_config_loaded_at_query_top_level,
+    source_table_config_loaded_at_field_config_level,
+    source_table_config_loaded_at_query_config_level,
+    source_table_config_loaded_at_query_not_set_if_field_present,
+    source_table_config_loaded_at_query_not_set_if_field_present_top_level,
+    table_config_loaded_at_field_top_level,
+    table_config_loaded_at_query_top_level,
 )
 
 
@@ -179,3 +189,149 @@ def test_invalid_config_source(self, project):
             run_dbt(["parse"])
         expected_msg = "'True and False' is not of type 'boolean'"
         assert expected_msg in str(excinfo.value)
+
+
+class TestSourceLoadedAtFieldConfigLevel:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": source_config_loaded_at_field_config_level,
+        }
+
+    def test_loaded_at_field_config(self, project):
+        manifest = run_dbt(["parse"])
+        source = manifest.sources["source.test.test_source.test_table"]
+        assert source.loaded_at_field == "id"
+        assert source.config.loaded_at_field == "id"
+
+
+class TestSourceLoadedAtQueryConfigLevel:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": source_config_loaded_at_query_config_level,
+        }
+
+    def test_loaded_at_field_config(self, project):
+        manifest = run_dbt(["parse"])
+        source = manifest.sources["source.test.test_source.test_table"]
+        assert source.loaded_at_query == "select 1"
+        assert source.config.loaded_at_query == "select 1"
+
+
+class TestTableLoadedAtFieldConfigLevel:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": source_table_config_loaded_at_field_config_level,
+        }
+
+    def test_loaded_at_field_config(self, project):
+        manifest = run_dbt(["parse"])
+        source = manifest.sources["source.test.test_source.test_table"]
+        assert source.loaded_at_field == "id"
+        assert source.config.loaded_at_field == "id"
+
+
+class TestTableLoadedAtQueryConfigLevel:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": source_table_config_loaded_at_query_config_level,
+        }
+
+    def test_loaded_at_field_config(self, project):
+        manifest = run_dbt(["parse"])
+        source = manifest.sources["source.test.test_source.test_table"]
+        assert source.loaded_at_query == "select 1"
+        assert source.config.loaded_at_query == "select 1"
+
+
+class TestSourceLoadedAtFieldTopLevel:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": source_config_loaded_at_field_top_level,
+        }
+
+    def test_loaded_at_field_config(self, project):
+        manifest = run_dbt(["parse"])
+        source = manifest.sources["source.test.test_source.test_table"]
+        assert source.loaded_at_field == "id"
+        assert source.config.loaded_at_field == "id"
+
+
+class TestSourceLoadedAtQueryTopLevel:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": source_config_loaded_at_query_top_level,
+        }
+
+    def test_loaded_at_query_config(self, project):
+        manifest = run_dbt(["parse"])
+        source = manifest.sources["source.test.test_source.test_table"]
+        assert source.loaded_at_query == "select 1"
+        assert source.config.loaded_at_query == "select 1"
+
+
+class TestTableLoadedAtFieldTopLevel:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": table_config_loaded_at_field_top_level,
+        }
+
+    def test_loaded_at_field_config(self, project):
+        manifest = run_dbt(["parse"])
+        source = manifest.sources["source.test.test_source.test_table"]
+        assert source.loaded_at_field == "id"
+        assert source.config.loaded_at_field == "id"
+
+
+class TestTableLoadedAtQueryTopLevel:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": table_config_loaded_at_query_top_level,
+        }
+
+    def test_loaded_at_query_config(self, project):
+        manifest = run_dbt(["parse"])
+        source = manifest.sources["source.test.test_source.test_table"]
+        assert source.loaded_at_query == "select 1"
+        assert source.config.loaded_at_query == "select 1"
+
+
+class TestTableLoadedAtQueryNoneWhenFieldSetConfigLevel:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": source_table_config_loaded_at_query_not_set_if_field_present,
+        }
+
+    def test_loaded_at_query_config(self, project):
+        manifest = run_dbt(["parse"])
+        source = manifest.sources["source.test.test_source.test_table"]
+        assert source.loaded_at_field == "id"
+        assert source.config.loaded_at_field == "id"
+
+        assert source.loaded_at_query is None
+        assert source.config.loaded_at_query is None
+
+
+class TestTableLoadedAtQueryNoneWhenFieldSetTopLevel:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": source_table_config_loaded_at_query_not_set_if_field_present_top_level,
+        }
+
+    def test_loaded_at_query_config(self, project):
+        manifest = run_dbt(["parse"])
+        source = manifest.sources["source.test.test_source.test_table"]
+        assert source.loaded_at_field == "id"
+        assert source.config.loaded_at_field == "id"
+
+        assert source.loaded_at_query is None
+        assert source.config.loaded_at_query is None