Repository: PrefectHQ/prefect

Issue:
The `prefect deploy` command and prefect.yaml file should support multiple deployment schedules
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar issue and didn't find it.
- [X] I searched the Prefect documentation for this issue.
- [X] I checked that this issue is related to Prefect and not one of its dependencies.

### Bug summary

Hi team,

I'm trying to add multiple schedules to one of my deployments (through `prefect.yaml` file within my project).

When only providing one schedule through the `schedule` directive, everything works fine:
```yaml
deployments:
- name: "my-deployment-test"
  schedule:
    cron: "15 3 * * *"
    timezone: "Europe/Zurich"
    active: true
  entrypoint: my_entrypoint.py:my_flow_fn
  work_pool:
    name: "dev-process-pool"
    work_queue_name: null
```

Running `prefect --no-prompt deploy` will work and I will see my schedule displayed in the UI.

However (using prefect `2.16` on both server and client side), when adding multiple schedules as seen in the documentation:
```yaml
deployments:
- name: "my-deployment-test"
  schedules:
    - cron: "15 3 * * *"
      timezone: "Europe/Zurich"
      active: true
    - cron: "0 7 * * *"
      timezone: "Europe/Zurich"
      active: true
  entrypoint: my_entrypoint.py:my_flow_fn
  work_pool:
    name: "dev-process-pool"
    work_queue_name: null
```

Running `prefect --no-prompt deploy` will work fine (no error), but looking into the UI I don't see any schedule for my deployment, which is also confirmed by the cli command `prefect deployment schedule ls xxxxx` which returns an empty table.

I'm pretty sure I'm doing something wrong considering the multiple schedule has just been released in 2.16.0 but I can't figure out what from looking at the documentation.

### Reproduction

```python3
prefect --no-prompt deploy
```


### Error

_No response_

### Versions

```Text
2.16.0
```


### Additional context

_No response_


Discussion:
Hi @TheoBabilon! We're continuing to roll out support for multiple schedules. Currently support exists in these places:
- The YAML files created with `prefect deployment build` and updated/applied with `prefect deployment apply` (these use different code than deployments managed with `prefect deploy`)
- The `Deployment` class
- The CLI commands you mentioned
- The UI's schedules interface

The support we're rolling out next includes:
- Deployments managed with the YAML files created with `prefect deploy` (what you asked about)
- Deployments managed with `flow.serve`, `flow.deploy`, `serve`, and `RunnerDeployment` (also different code)
It looks like the docs are wrong, however. We'll get those updated to explain that they pertain to YAML files created with `prefect deployment build`.
Thanks for the detailed explanation @abrookins !
I'll look into the next releases to benefit from it on `prefect deploy` command but in the meantime it's fine, I'll just add additional schedules through the UI.

Resolving patch:
diff --git a/src/prefect/cli/_prompts.py b/src/prefect/cli/_prompts.py
--- a/src/prefect/cli/_prompts.py
+++ b/src/prefect/cli/_prompts.py
@@ -5,7 +5,7 @@
 import shutil
 from datetime import timedelta
 from getpass import GetPassWarning
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Optional
 
 import readchar
 from rich.console import Console, Group
@@ -19,8 +19,8 @@
 from prefect.client.collections import get_collections_metadata_client
 from prefect.client.orchestration import PrefectClient
 from prefect.client.schemas.actions import BlockDocumentCreate, WorkPoolCreate
+from prefect.client.schemas.objects import MinimalDeploymentSchedule
 from prefect.client.schemas.schedules import (
-    SCHEDULE_TYPES,
     CronSchedule,
     IntervalSchedule,
     RRuleSchedule,
@@ -328,26 +328,40 @@ def prompt_schedule_type(console):
     return selection["type"]
 
 
-def prompt_schedule(console) -> Tuple[SCHEDULE_TYPES, bool]:
+def prompt_schedules(console) -> List[MinimalDeploymentSchedule]:
     """
-    Prompt the user for a schedule type. Once a schedule type is selected, prompt
-    the user for the schedule details and return the schedule.
+    Prompt the user to configure schedules for a deployment.
     """
-    schedule_type = prompt_schedule_type(console)
-    if schedule_type == "Cron":
-        schedule = prompt_cron_schedule(console)
-    elif schedule_type == "Interval":
-        schedule = prompt_interval_schedule(console)
-    elif schedule_type == "RRule":
-        schedule = prompt_rrule_schedule(console)
-    else:
-        raise Exception("Invalid schedule type")
+    schedules = []
 
-    is_schedule_active = confirm(
-        "Would you like to activate this schedule?", default=True
-    )
+    if confirm(
+        "Would you like to configure schedules for this deployment?", default=True
+    ):
+        add_schedule = True
+        while add_schedule:
+            schedule_type = prompt_schedule_type(console)
+            if schedule_type == "Cron":
+                schedule = prompt_cron_schedule(console)
+            elif schedule_type == "Interval":
+                schedule = prompt_interval_schedule(console)
+            elif schedule_type == "RRule":
+                schedule = prompt_rrule_schedule(console)
+            else:
+                raise Exception("Invalid schedule type")
+
+            is_schedule_active = confirm(
+                "Would you like to activate this schedule?", default=True
+            )
+
+            schedules.append(
+                MinimalDeploymentSchedule(schedule=schedule, active=is_schedule_active)
+            )
+
+            add_schedule = confirm(
+                "Would you like to add another schedule?", default=False
+            )
 
-    return (schedule, is_schedule_active)
+    return schedules
 
 
 @inject_client
diff --git a/src/prefect/cli/deploy.py b/src/prefect/cli/deploy.py
--- a/src/prefect/cli/deploy.py
+++ b/src/prefect/cli/deploy.py
@@ -26,7 +26,7 @@
     prompt_build_custom_docker_image,
     prompt_entrypoint,
     prompt_push_custom_docker_image,
-    prompt_schedule,
+    prompt_schedules,
     prompt_select_blob_storage_credentials,
     prompt_select_from_table,
     prompt_select_remote_flow_storage,
@@ -38,8 +38,8 @@
 )
 from prefect.cli.root import app, is_interactive
 from prefect.client.orchestration import PrefectClient, ServerType
+from prefect.client.schemas.objects import MinimalDeploymentSchedule
 from prefect.client.schemas.schedules import (
-    SCHEDULE_TYPES,
     CronSchedule,
     IntervalSchedule,
     RRuleSchedule,
@@ -145,12 +145,12 @@ async def deploy(
             " format of key=value string or a JSON object"
         ),
     ),
-    cron: str = typer.Option(
+    cron: List[str] = typer.Option(
         None,
         "--cron",
         help="A cron string that will be used to set a CronSchedule on the deployment.",
     ),
-    interval: int = typer.Option(
+    interval: List[int] = typer.Option(
         None,
         "--interval",
         help=(
@@ -159,9 +159,9 @@ async def deploy(
         ),
     ),
     interval_anchor: Optional[str] = typer.Option(
-        None, "--anchor-date", help="The anchor date for an interval schedule"
+        None, "--anchor-date", help="The anchor date for all interval schedules"
     ),
-    rrule: str = typer.Option(
+    rrule: List[str] = typer.Option(
         None,
         "--rrule",
         help="An RRule that will be used to set an RRuleSchedule on the deployment.",
@@ -330,6 +330,7 @@ async def _run_single_deploy(
     should_prompt_for_save = is_interactive() and not ci
 
     deploy_config = _merge_with_default_deploy_config(deploy_config)
+    deploy_config = _handle_deprecated_schedule_fields(deploy_config)
     (
         deploy_config,
         variable_overrides,
@@ -345,28 +346,6 @@ async def _run_single_deploy(
     # check for env var placeholders early so users can pass work pool names, etc.
     deploy_config = apply_values(deploy_config, os.environ, remove_notset=False)
 
-    if get_from_dict(deploy_config, "schedule.anchor_date") and not get_from_dict(
-        deploy_config, "schedule.interval"
-    ):
-        raise ValueError(
-            "An anchor date can only be provided with an interval schedule"
-        )
-
-    number_of_schedules_provided = len(
-        [
-            value
-            for value in (
-                get_from_dict(deploy_config, "schedule.cron"),
-                get_from_dict(deploy_config, "schedule.rrule"),
-                get_from_dict(deploy_config, "schedule.interval"),
-            )
-            if value is not None
-        ]
-    )
-
-    if number_of_schedules_provided > 1:
-        raise ValueError("Only one schedule type can be provided.")
-
     if not deploy_config.get("flow_name") and not deploy_config.get("entrypoint"):
         if not is_interactive() and not ci:
             raise ValueError(
@@ -452,10 +431,7 @@ async def _run_single_deploy(
 
     deploy_config["parameter_openapi_schema"] = parameter_schema(flow)
 
-    (
-        deploy_config["schedule"],
-        deploy_config["is_schedule_active"],
-    ) = _construct_schedule(deploy_config, ci=ci)
+    deploy_config["schedules"] = _construct_schedules(deploy_config, ci=ci)
 
     # determine work pool
     work_pool_name = get_from_dict(deploy_config, "work_pool.name")
@@ -642,10 +618,12 @@ async def _run_single_deploy(
     deploy_config_before_templating = deepcopy(deploy_config)
     ## apply templating from build and push steps to the final deployment spec
     _parameter_schema = deploy_config.pop("parameter_openapi_schema")
-    _schedule = deploy_config.pop("schedule")
+
+    _schedules = deploy_config.pop("schedules")
+
     deploy_config = apply_values(deploy_config, step_outputs)
     deploy_config["parameter_openapi_schema"] = _parameter_schema
-    deploy_config["schedule"] = _schedule
+    deploy_config["schedules"] = _schedules
 
     pull_steps = apply_values(pull_steps, step_outputs, remove_notset=False)
 
@@ -657,8 +635,8 @@ async def _run_single_deploy(
         work_queue_name=get_from_dict(deploy_config, "work_pool.work_queue_name"),
         work_pool_name=get_from_dict(deploy_config, "work_pool.name"),
         version=deploy_config.get("version"),
-        schedule=deploy_config.get("schedule"),
-        is_schedule_active=deploy_config.get("is_schedule_active"),
+        schedules=deploy_config.get("schedules"),
+        paused=deploy_config.get("paused"),
         enforce_parameter_schema=deploy_config.get("enforce_parameter_schema", False),
         parameter_openapi_schema=deploy_config.get("parameter_openapi_schema").dict(),
         parameters=deploy_config.get("parameters"),
@@ -802,10 +780,10 @@ async def _run_multi_deploy(
         )
 
 
-def _construct_schedule(
+def _construct_schedules(
     deploy_config: Dict,
     ci: bool = False,
-) -> Tuple[Optional[SCHEDULE_TYPES], Optional[bool]]:
+) -> List[MinimalDeploymentSchedule]:
     """
     Constructs a schedule from a deployment configuration.
 
@@ -814,15 +792,33 @@ def _construct_schedule(
         ci: Disable interactive prompts if True
 
     Returns:
-        A schedule object
+        A list of schedule objects
     """
-    schedule = deploy_config.get("schedule", NotSet)
-    cron = get_from_dict(deploy_config, "schedule.cron")
-    interval = get_from_dict(deploy_config, "schedule.interval")
-    anchor_date = get_from_dict(deploy_config, "schedule.anchor_date")
-    rrule = get_from_dict(deploy_config, "schedule.rrule")
-    timezone = get_from_dict(deploy_config, "schedule.timezone")
-    schedule_active = get_from_dict(deploy_config, "schedule.active", True)
+    schedule_configs = deploy_config.get("schedules", NotSet)
+
+    if schedule_configs is not NotSet:
+        schedules = [
+            _schedule_config_to_deployment_schedule(schedule_config)
+            for schedule_config in schedule_configs
+        ]
+    elif schedule_configs is NotSet:
+        if not ci and is_interactive():
+            schedules = prompt_schedules(app.console)
+        else:
+            schedules = []
+
+    return schedules
+
+
+def _schedule_config_to_deployment_schedule(
+    schedule_config: Dict,
+) -> MinimalDeploymentSchedule:
+    cron = schedule_config.get("cron")
+    interval = schedule_config.get("interval")
+    anchor_date = schedule_config.get("anchor_date")
+    rrule = schedule_config.get("rrule")
+    timezone = schedule_config.get("timezone")
+    schedule_active = schedule_config.get("active", True)
 
     if cron:
         cron_kwargs = {"cron": cron, "timezone": timezone}
@@ -846,28 +842,12 @@ def _construct_schedule(
                 schedule.timezone = timezone
         except json.JSONDecodeError:
             schedule = RRuleSchedule(rrule=rrule, timezone=timezone)
-    elif schedule is NotSet:
-        if (
-            not ci
-            and is_interactive()
-            and confirm(
-                "Would you like to configure a schedule for this deployment?",
-                default=True,
-                console=app.console,
-            )
-        ):
-            schedule, schedule_active = prompt_schedule(app.console)
-        else:
-            schedule = None
-            schedule_active = None
     else:
-        schedule = None
-        schedule_active = None
-
-    if schedule_active is None:
-        schedule_active = True
+        raise ValueError(
+            f"Unknown schedule type. Please provide a valid schedule. schedule={schedule_config}"
+        )
 
-    return (schedule, schedule_active)
+    return MinimalDeploymentSchedule(schedule=schedule, active=schedule_active)
 
 
 def _merge_with_default_deploy_config(deploy_config: Dict):
@@ -1525,13 +1505,12 @@ def _apply_cli_options_to_deploy_config(deploy_config, cli_options):
             else:
                 deploy_config["work_pool"][cli_option] = cli_value
 
-        elif (
-            cli_option in ["cron", "interval", "anchor_date", "rrule", "timezone"]
-            and cli_value
-        ):
-            if not isinstance(deploy_config.get("schedule"), dict):
-                deploy_config["schedule"] = {}
-            deploy_config["schedule"][cli_option] = cli_value
+        elif cli_option in ["cron", "interval", "rrule"] and cli_value:
+            if not isinstance(deploy_config.get("schedules"), list):
+                deploy_config["schedules"] = []
+
+            for value in cli_value:
+                deploy_config["schedules"].append({cli_option: value})
 
         elif cli_option in ["param", "params"] and cli_value:
             parameters = dict()
@@ -1555,6 +1534,16 @@ def _apply_cli_options_to_deploy_config(deploy_config, cli_options):
                 deploy_config["parameters"] = {}
             deploy_config["parameters"].update(parameters)
 
+    anchor_date = cli_options.get("anchor_date")
+    timezone = cli_options.get("timezone")
+
+    # Apply anchor_date and timezone to new and existing schedules
+    for schedule_config in deploy_config.get("schedules", []):
+        if anchor_date and schedule_config.get("interval"):
+            schedule_config["anchor_date"] = anchor_date
+        if timezone:
+            schedule_config["timezone"] = timezone
+
     return deploy_config, variable_overrides
 
 
@@ -1664,3 +1653,44 @@ def _gather_deployment_trigger_definitions(
         return trigger_specs
 
     return existing_triggers
+
+
+def _handle_deprecated_schedule_fields(deploy_config: Dict):
+    deploy_config = deepcopy(deploy_config)
+
+    legacy_schedule = deploy_config.get("schedule", NotSet)
+    schedule_configs = deploy_config.get("schedules", NotSet)
+
+    if (
+        legacy_schedule
+        and legacy_schedule is not NotSet
+        and schedule_configs is not NotSet
+    ):
+        raise ValueError(
+            "Both 'schedule' and 'schedules' keys are present in the deployment"
+            " configuration. Please use only use `schedules`."
+        )
+
+    if legacy_schedule and isinstance(legacy_schedule, dict):
+        # The yaml has a legacy schedule key, we should honor whatever
+        # is there while still appending these new schedules.
+        deploy_config["schedules"] = [deploy_config["schedule"]]
+
+        app.console.print(
+            generate_deprecation_message(
+                "Defining a schedule via the `schedule` key in the deployment",
+                start_date="Mar 2023",
+                help=(
+                    "Please use `schedules` instead by renaming the "
+                    "`schedule` key to `schedules` and providing a list of "
+                    "schedule objects."
+                ),
+            ),
+            style="yellow",
+        )
+
+    # Pop the legacy schedule keys if they exist
+    deploy_config.pop("schedule", None)
+    deploy_config.pop("is_schedule_active", None)
+
+    return deploy_config
diff --git a/src/prefect/deployments/base.py b/src/prefect/deployments/base.py
--- a/src/prefect/deployments/base.py
+++ b/src/prefect/deployments/base.py
@@ -13,7 +13,7 @@
 import sys
 from copy import deepcopy
 from pathlib import Path
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, cast
 
 import anyio
 import yaml
@@ -27,6 +27,7 @@
 
 from ruamel.yaml import YAML
 
+from prefect.client.schemas.objects import MinimalDeploymentSchedule
 from prefect.client.schemas.schedules import IntervalSchedule
 from prefect.flows import load_flow_from_entrypoint
 from prefect.logging import get_logger
@@ -406,22 +407,50 @@ def _format_deployment_for_saving_to_prefect_file(
 
     if deployment.get("schedule"):
         if isinstance(deployment["schedule"], IntervalSchedule):
-            deployment["schedule"] = deployment["schedule"].dict()
-            deployment["schedule"]["interval"] = deployment["schedule"][
-                "interval"
-            ].total_seconds()
-            deployment["schedule"]["anchor_date"] = deployment["schedule"][
-                "anchor_date"
-            ].isoformat()
+            deployment["schedule"] = _interval_schedule_to_dict(deployment["schedule"])
         elif isinstance(deployment["schedule"], BaseModel):
             deployment["schedule"] = deployment["schedule"].dict()
 
         if "is_schedule_active" in deployment:
             deployment["schedule"]["active"] = deployment.pop("is_schedule_active")
 
+    if deployment.get("schedules"):
+        schedules = []
+        for deployment_schedule in cast(
+            List[MinimalDeploymentSchedule], deployment["schedules"]
+        ):
+            if isinstance(deployment_schedule.schedule, IntervalSchedule):
+                schedule_config = _interval_schedule_to_dict(
+                    deployment_schedule.schedule
+                )
+            elif isinstance(deployment_schedule.schedule, BaseModel):
+                schedule_config = deployment_schedule.schedule.dict()
+
+            schedule_config["active"] = deployment_schedule.active
+            schedules.append(schedule_config)
+
+        deployment["schedules"] = schedules
+
     return deployment
 
 
+def _interval_schedule_to_dict(schedule: IntervalSchedule) -> Dict:
+    """
+    Converts an IntervalSchedule to a dictionary.
+
+    Args:
+        - schedule (IntervalSchedule): the schedule to convert
+
+    Returns:
+        - Dict: the schedule as a dictionary
+    """
+    schedule_config = schedule.dict()
+    schedule_config["interval"] = schedule_config["interval"].total_seconds()
+    schedule_config["anchor_date"] = schedule_config["anchor_date"].isoformat()
+
+    return schedule_config
+
+
 def _save_deployment_to_prefect_file(
     deployment: Dict,
     build_steps: Optional[List[Dict]] = None,


Test patch:
diff --git a/src/prefect/testing/cli.py b/src/prefect/testing/cli.py
--- a/src/prefect/testing/cli.py
+++ b/src/prefect/testing/cli.py
@@ -76,13 +76,13 @@ def invoke_and_assert(
         raise RuntimeError(
             textwrap.dedent(
                 """
-                You cannot run `invoke_and_assert` directly from an async 
-                function. If you need to run `invoke_and_assert` in an async 
-                function, run it with `run_sync_in_worker_thread`. 
+                You cannot run `invoke_and_assert` directly from an async
+                function. If you need to run `invoke_and_assert` in an async
+                function, run it with `run_sync_in_worker_thread`.
 
-                Example: 
+                Example:
                     run_sync_in_worker_thread(
-                        invoke_and_assert, 
+                        invoke_and_assert,
                         command=['my', 'command'],
                         expected_code=0,
                     )
diff --git a/tests/cli/test_deploy.py b/tests/cli/test_deploy.py
--- a/tests/cli/test_deploy.py
+++ b/tests/cli/test_deploy.py
@@ -27,6 +27,11 @@
 )
 from prefect.client.orchestration import PrefectClient, ServerType
 from prefect.client.schemas.objects import WorkPool
+from prefect.client.schemas.schedules import (
+    CronSchedule,
+    IntervalSchedule,
+    RRuleSchedule,
+)
 from prefect.deployments import register_flow
 from prefect.deployments.base import (
     _save_deployment_to_prefect_file,
@@ -43,7 +48,6 @@
     BlockTypeCreate,
     WorkPoolCreate,
 )
-from prefect.server.schemas.schedules import CronSchedule
 from prefect.settings import (
     PREFECT_DEFAULT_WORK_POOL_NAME,
     PREFECT_UI_URL,
@@ -2674,8 +2678,10 @@ async def test_passing_cron_schedules_to_deploy(self, work_pool, prefect_client)
         deployment = await prefect_client.read_deployment_by_name(
             "An important name/test-name"
         )
-        assert deployment.schedule.cron == "0 4 * * *"
-        assert deployment.schedule.timezone == "Europe/Berlin"
+
+        schedule = deployment.schedules[0].schedule
+        assert schedule.cron == "0 4 * * *"
+        assert schedule.timezone == "Europe/Berlin"
 
     @pytest.mark.usefixtures("project_dir")
     async def test_deployment_yaml_cron_schedule(self, work_pool, prefect_client):
@@ -2701,8 +2707,9 @@ async def test_deployment_yaml_cron_schedule(self, work_pool, prefect_client):
         deployment = await prefect_client.read_deployment_by_name(
             "An important name/test-name"
         )
-        assert deployment.schedule.cron == "0 4 * * *"
-        assert deployment.schedule.timezone == "America/Chicago"
+        schedule = deployment.schedules[0].schedule
+        assert schedule.cron == "0 4 * * *"
+        assert schedule.timezone == "America/Chicago"
 
     @pytest.mark.usefixtures("project_dir")
     async def test_deployment_yaml_cron_schedule_timezone_cli(
@@ -2784,19 +2791,6 @@ async def test_interval_schedule_deployment_yaml(self, prefect_client, work_pool
         assert deployment.schedule.anchor_date == pendulum.parse("2040-02-02")
         assert deployment.schedule.timezone == "America/Chicago"
 
-    @pytest.mark.usefixtures("project_dir")
-    async def test_passing_anchor_without_interval_exits(self):
-        await run_sync_in_worker_thread(
-            invoke_and_assert,
-            command=(
-                "deploy ./flows/hello.py:my_flow -n test-name --anchor-date 2040-02-02"
-            ),
-            expected_code=1,
-            expected_output_contains=(
-                "An anchor date can only be provided with an interval schedule"
-            ),
-        )
-
     @pytest.mark.usefixtures("project_dir")
     async def test_parsing_rrule_schedule_string_literal(
         self, prefect_client, work_pool
@@ -2851,24 +2845,160 @@ async def test_rrule_deployment_yaml(self, work_pool, prefect_client):
         )
 
     @pytest.mark.usefixtures("project_dir")
-    @pytest.mark.parametrize(
-        "schedules",
-        [
-            ["--cron", "cron-str", "--interval", "42"],
-            ["--rrule", "rrule-str", "--interval", "42"],
-            ["--rrule", "rrule-str", "--cron", "cron-str"],
-            ["--rrule", "rrule-str", "--cron", "cron-str", "--interval", "42"],
-        ],
-    )
-    async def test_providing_multiple_schedules_exits_with_error(self, schedules):
+    async def test_can_provide_multiple_schedules_via_command(
+        self, prefect_client, work_pool
+    ):
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=f"deploy ./flows/hello.py:my_flow -n test-name --cron '* * * * *' --interval 42 --rrule 'FREQ=HOURLY' --pool {work_pool.name}",
+            expected_code=0,
+            expected_output_contains=[
+                "Deployment 'An important name/test-name' successfully created"
+            ],
+        )
+
+        deployment = await prefect_client.read_deployment_by_name(
+            "An important name/test-name"
+        )
+
+        schedule_config = {}
+        for deployment_schedule in deployment.schedules:
+            schedule = deployment_schedule.schedule
+            if isinstance(schedule, IntervalSchedule):
+                schedule_config["interval"] = schedule.interval
+            elif isinstance(schedule, CronSchedule):
+                schedule_config["cron"] = schedule.cron
+            elif isinstance(schedule, RRuleSchedule):
+                schedule_config["rrule"] = schedule.rrule
+            else:
+                raise AssertionError("Unknown schedule type received")
+
+        assert schedule_config == {
+            "interval": timedelta(seconds=42),
+            "cron": "* * * * *",
+            "rrule": "FREQ=HOURLY",
+        }
+
+    @pytest.mark.usefixtures("project_dir")
+    async def test_can_provide_multiple_schedules_via_yaml(
+        self, prefect_client, work_pool
+    ):
+        prefect_yaml = Path("prefect.yaml")
+        with prefect_yaml.open(mode="r") as f:
+            deploy_config = yaml.safe_load(f)
+
+        deploy_config["deployments"][0]["name"] = "test-name"
+        deploy_config["deployments"][0]["schedules"] = [
+            {"interval": 42},
+            {"cron": "* * * * *"},
+            {"rrule": "FREQ=HOURLY"},
+        ]
+
+        with prefect_yaml.open(mode="w") as f:
+            yaml.safe_dump(deploy_config, f)
+
         await run_sync_in_worker_thread(
             invoke_and_assert,
-            command="deploy ./flows/hello.py:my_flow -n test-name "
-            + " ".join(schedules),
+            command=f"deploy ./flows/hello.py:my_flow -n test-name --pool {work_pool.name}",
+            expected_code=0,
+            expected_output_contains=[
+                "Deployment 'An important name/test-name' successfully created"
+            ],
+        )
+
+        deployment = await prefect_client.read_deployment_by_name(
+            "An important name/test-name"
+        )
+
+        schedule_config = {}
+        for deployment_schedule in deployment.schedules:
+            schedule = deployment_schedule.schedule
+            if isinstance(schedule, IntervalSchedule):
+                schedule_config["interval"] = schedule.interval
+            elif isinstance(schedule, CronSchedule):
+                schedule_config["cron"] = schedule.cron
+            elif isinstance(schedule, RRuleSchedule):
+                schedule_config["rrule"] = schedule.rrule
+            else:
+                raise AssertionError("Unknown schedule type received")
+
+        assert schedule_config == {
+            "interval": timedelta(seconds=42),
+            "cron": "* * * * *",
+            "rrule": "FREQ=HOURLY",
+        }
+
+    @pytest.mark.usefixtures("project_dir")
+    async def test_yaml_with_schedule_and_schedules_raises_error(self, work_pool):
+        prefect_yaml = Path("prefect.yaml")
+        with prefect_yaml.open(mode="r") as f:
+            deploy_config = yaml.safe_load(f)
+
+        deploy_config["deployments"][0]["name"] = "test-name"
+        deploy_config["deployments"][0]["schedule"]["interval"] = 42
+        deploy_config["deployments"][0]["schedules"] = [{"interval": 42}]
+
+        with prefect_yaml.open(mode="w") as f:
+            yaml.safe_dump(deploy_config, f)
+
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=(
+                f"deploy ./flows/hello.py:my_flow -n test-name --pool {work_pool.name}"
+            ),
             expected_code=1,
-            expected_output_contains=["Only one schedule type can be provided."],
+            expected_output_contains="Both 'schedule' and 'schedules' keys are present in the deployment configuration. Please use only use `schedules`.",
         )
 
+    @pytest.mark.usefixtures("project_dir")
+    async def test_yaml_with_schedule_prints_deprecation_warning(self, work_pool):
+        prefect_yaml = Path("prefect.yaml")
+        with prefect_yaml.open(mode="r") as f:
+            deploy_config = yaml.safe_load(f)
+
+        deploy_config["deployments"][0]["name"] = "test-name"
+        deploy_config["deployments"][0]["schedule"]["interval"] = 42
+
+        with prefect_yaml.open(mode="w") as f:
+            yaml.safe_dump(deploy_config, f)
+
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=(
+                f"deploy ./flows/hello.py:my_flow -n test-name --pool {work_pool.name}"
+            ),
+            expected_code=0,
+            expected_output_contains="Defining a schedule via the `schedule` key in the deployment",
+        )
+
+    @pytest.mark.usefixtures("project_dir")
+    async def test_can_provide_multiple_schedules_of_the_same_type_via_command(
+        self, prefect_client, work_pool
+    ):
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=f"deploy ./flows/hello.py:my_flow -n test-name --cron '* * * * *' --cron '0 * * * *' --pool {work_pool.name}",
+            expected_code=0,
+            expected_output_contains=[
+                "Deployment 'An important name/test-name' successfully created"
+            ],
+        )
+
+        deployment = await prefect_client.read_deployment_by_name(
+            "An important name/test-name"
+        )
+
+        schedules = set()
+        for deployment_schedule in deployment.schedules:
+            schedule = deployment_schedule.schedule
+            assert isinstance(schedule, CronSchedule)
+            schedules.add(schedule.cron)
+
+        assert schedules == {
+            "* * * * *",
+            "0 * * * *",
+        }
+
     @pytest.mark.usefixtures("interactive_console", "project_dir")
     async def test_deploy_interval_schedule_interactive(
         self, prefect_client, work_pool
@@ -2881,25 +3011,22 @@ async def test_deploy_interval_schedule_interactive(
             user_input=(
                 # Confirm schedule creation
                 readchar.key.ENTER
-                +
                 # Select interval schedule
-                readchar.key.ENTER
-                +
+                + readchar.key.ENTER
                 # Enter invalid interval
-                "bad interval"
+                + "bad interval"
                 + readchar.key.ENTER
-                +
                 # Enter another invalid interval
-                "0"
+                + "0"
                 + readchar.key.ENTER
-                +
                 # Enter valid interval
-                "42"
+                + "42"
                 + readchar.key.ENTER
-                # Decline remote storage
-                + "n"
+                # accept schedule being active
                 + readchar.key.ENTER
-                # Decline save
+                # decline adding another schedule
+                + readchar.key.ENTER
+                # decline save
                 + "n"
                 + readchar.key.ENTER
             ),
@@ -2914,7 +3041,7 @@ async def test_deploy_interval_schedule_interactive(
         deployment = await prefect_client.read_deployment_by_name(
             "An important name/test-name"
         )
-        assert deployment.schedule.interval == timedelta(seconds=42)
+        assert deployment.schedules[0].schedule.interval == timedelta(seconds=42)
 
     @pytest.mark.usefixtures("interactive_console", "project_dir")
     async def test_deploy_cron_schedule_interactive(self, prefect_client, work_pool):
@@ -2926,30 +3053,25 @@ async def test_deploy_cron_schedule_interactive(self, prefect_client, work_pool)
             user_input=(
                 # Confirm schedule creation
                 readchar.key.ENTER
-                +
                 # Select cron schedule
-                readchar.key.DOWN
+                + readchar.key.DOWN
                 + readchar.key.ENTER
-                +
                 # Enter invalid cron string
-                "bad cron string"
+                + "bad cron string"
                 + readchar.key.ENTER
-                +
                 # Enter cron
-                "* * * * *"
+                + "* * * * *"
                 + readchar.key.ENTER
-                +
                 # Enter invalid timezone
-                "bad timezone"
+                + "bad timezone"
                 + readchar.key.ENTER
-                +
                 # Select default timezone
-                readchar.key.ENTER
-                +
-                # Decline remote storage
-                "n"
                 + readchar.key.ENTER
-                # Decline save
+                # accept schedule being active
+                + readchar.key.ENTER
+                # decline adding another schedule
+                + readchar.key.ENTER
+                # decline save
                 + "n"
                 + readchar.key.ENTER
             ),
@@ -2965,7 +3087,7 @@ async def test_deploy_cron_schedule_interactive(self, prefect_client, work_pool)
         deployment = await prefect_client.read_deployment_by_name(
             "An important name/test-name"
         )
-        assert deployment.schedule.cron == "* * * * *"
+        assert deployment.schedules[0].schedule.cron == "* * * * *"
 
     @pytest.mark.usefixtures("interactive_console", "project_dir")
     async def test_deploy_rrule_schedule_interactive(self, prefect_client, work_pool):
@@ -2977,32 +3099,27 @@ async def test_deploy_rrule_schedule_interactive(self, prefect_client, work_pool
             user_input=(
                 # Confirm schedule creation
                 readchar.key.ENTER
-                +
                 # Select rrule schedule
-                readchar.key.DOWN
+                + readchar.key.DOWN
                 + readchar.key.DOWN
                 + readchar.key.ENTER
-                +
                 # Enter invalid rrule string
-                "bad rrule string"
+                + "bad rrule string"
                 + readchar.key.ENTER
-                +
                 # Enter valid rrule string
-                "FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20240730T040000Z"
+                + "FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20240730T040000Z"
                 + readchar.key.ENTER
                 # Enter invalid timezone
                 + "bad timezone"
                 + readchar.key.ENTER
-                +
                 # Select default timezone
-                readchar.key.ENTER
-                +
-                # Decline remote storage
-                "n"
                 + readchar.key.ENTER
-                +
-                # Decline save
-                "n"
+                # accept schedule being active
+                + readchar.key.ENTER
+                # decline adding another schedule
+                + readchar.key.ENTER
+                # decline save
+                + "n"
                 + readchar.key.ENTER
             ),
             expected_code=0,
@@ -3012,7 +3129,7 @@ async def test_deploy_rrule_schedule_interactive(self, prefect_client, work_pool
             "An important name/test-name"
         )
         assert (
-            deployment.schedule.rrule
+            deployment.schedules[0].schedule.rrule
             == "FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20240730T040000Z"
         )
 
@@ -3090,9 +3207,11 @@ async def test_deploy_with_inactive_schedule(self, work_pool, prefect_client):
         deployment = await prefect_client.read_deployment_by_name(
             "An important name/test-name"
         )
-        assert deployment.is_schedule_active is False
-        assert deployment.schedule.cron == "0 4 * * *"
-        assert deployment.schedule.timezone == "America/Chicago"
+
+        deployment_schedule = deployment.schedules[0]
+        assert deployment_schedule.active is False
+        assert deployment_schedule.schedule.cron == "0 4 * * *"
+        assert deployment_schedule.schedule.timezone == "America/Chicago"
 
 
 class TestMultiDeploy:
@@ -3196,9 +3315,9 @@ async def test_deploy_all_schedules_remain_inactive(
         )
 
         assert deployment1.name == "test-name-1"
-        assert deployment1.is_schedule_active is True
+        assert deployment1.schedules[0].active is True
         assert deployment2.name == "test-name-2"
-        assert deployment2.is_schedule_active is False
+        assert deployment2.schedules[0].active is False
 
     async def test_deploy_selected_deployments(
         self, project_dir, prefect_client, work_pool
@@ -4579,7 +4698,7 @@ def test_save_user_inputs_no_existing_prefect_file(self):
         assert len(config["deployments"]) == 1
         assert config["deployments"][0]["name"] == "default"
         assert config["deployments"][0]["entrypoint"] == "flows/hello.py:my_flow"
-        assert config["deployments"][0]["schedule"] is None
+        assert config["deployments"][0]["schedules"] == []
         assert config["deployments"][0]["work_pool"]["name"] == "inflatable"
 
     def test_save_user_inputs_existing_prefect_file(self):
@@ -4625,7 +4744,7 @@ def test_save_user_inputs_existing_prefect_file(self):
         assert len(config["deployments"]) == 2
         assert config["deployments"][1]["name"] == "default"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
-        assert config["deployments"][1]["schedule"] is None
+        assert config["deployments"][1]["schedules"] == []
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
 
     def test_save_user_inputs_with_interval_schedule(self):
@@ -4646,6 +4765,8 @@ def test_save_user_inputs_with_interval_schedule(self):
                 + readchar.key.ENTER
                 # accept schedule being active
                 + readchar.key.ENTER
+                # decline adding another schedule
+                + readchar.key.ENTER
                 +
                 # accept create work pool
                 readchar.key.ENTER
@@ -4677,10 +4798,12 @@ def test_save_user_inputs_with_interval_schedule(self):
         assert config["deployments"][1]["name"] == "default"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
-        assert config["deployments"][1]["schedule"]["interval"] == 3600
-        assert config["deployments"][1]["schedule"]["timezone"] == "UTC"
-        assert config["deployments"][1]["schedule"]["anchor_date"] is not None
-        assert config["deployments"][1]["schedule"]["active"]
+
+        schedule = config["deployments"][1]["schedules"][0]
+        assert schedule["interval"] == 3600
+        assert schedule["timezone"] == "UTC"
+        assert schedule["anchor_date"] is not None
+        assert schedule["active"]
 
     def test_save_user_inputs_with_cron_schedule(self):
         invoke_and_assert(
@@ -4695,24 +4818,21 @@ def test_save_user_inputs_with_cron_schedule(self):
                 # select cron schedule
                 readchar.key.DOWN
                 + readchar.key.ENTER
-                +
                 # enter cron schedule
-                "* * * * *"
+                + "* * * * *"
                 + readchar.key.ENTER
-                +
                 # accept default timezone
-                readchar.key.ENTER
+                + readchar.key.ENTER
                 # accept schedule being active
                 + readchar.key.ENTER
-                +
+                # decline adding another schedule
+                + readchar.key.ENTER
                 # accept create work pool
-                readchar.key.ENTER
-                +
+                + readchar.key.ENTER
                 # choose process work pool
-                readchar.key.ENTER
-                +
+                + readchar.key.ENTER
                 # enter work pool name
-                "inflatable"
+                + "inflatable"
                 + readchar.key.ENTER
                 # accept save user inputs
                 + "y"
@@ -4735,10 +4855,14 @@ def test_save_user_inputs_with_cron_schedule(self):
         assert config["deployments"][1]["name"] == "default"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
-        assert config["deployments"][1]["schedule"]["cron"] == "* * * * *"
-        assert config["deployments"][1]["schedule"]["timezone"] == "UTC"
-        assert config["deployments"][1]["schedule"]["day_or"]
-        assert config["deployments"][1]["schedule"]["active"]
+
+        schedule = config["deployments"][1]["schedules"][0]
+        assert schedule == {
+            "cron": "* * * * *",
+            "day_or": True,
+            "timezone": "UTC",
+            "active": True,
+        }
 
     def test_deploy_existing_deployment_with_no_changes_does_not_prompt_save(self):
         # Set up initial deployment deployment
@@ -4756,6 +4880,9 @@ def test_deploy_existing_deployment_with_no_changes_does_not_prompt_save(self):
                 + readchar.key.ENTER
                 # enter cron schedule
                 + "* * * * *"
+                # accept schedule being active
+                + readchar.key.ENTER
+                # decline adding another schedule
                 + readchar.key.ENTER
                 # accept default timezone
                 + readchar.key.ENTER
@@ -4794,7 +4921,7 @@ def test_deploy_existing_deployment_with_no_changes_does_not_prompt_save(self):
         assert config["deployments"][1]["name"] == "existing-deployment"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
-        assert config["deployments"][1]["schedule"] == {
+        assert config["deployments"][1]["schedules"][0] == {
             "cron": "* * * * *",
             "day_or": True,
             "timezone": "UTC",
@@ -4833,7 +4960,7 @@ def test_deploy_existing_deployment_with_no_changes_does_not_prompt_save(self):
         assert config["deployments"][1]["name"] == "existing-deployment"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
-        assert config["deployments"][1]["schedule"] == {
+        assert config["deployments"][1]["schedules"][0] == {
             "cron": "* * * * *",
             "day_or": True,
             "timezone": "UTC",
@@ -4884,7 +5011,7 @@ def test_deploy_existing_deployment_with_changes_prompts_save(self):
         assert config["deployments"][1]["name"] == "existing-deployment"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
-        assert config["deployments"][1]["schedule"] is None
+        assert config["deployments"][1]["schedules"] == []
 
         invoke_and_assert(
             command="deploy -n existing-deployment --cron '* * * * *'",
@@ -4922,7 +5049,7 @@ def test_deploy_existing_deployment_with_changes_prompts_save(self):
         assert config["deployments"][1]["name"] == "existing-deployment"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
-        assert config["deployments"][1]["schedule"]["cron"] == "* * * * *"
+        assert config["deployments"][1]["schedules"][0]["cron"] == "* * * * *"
 
     def test_save_user_inputs_with_rrule_schedule(self):
         invoke_and_assert(
@@ -4944,6 +5071,8 @@ def test_save_user_inputs_with_rrule_schedule(self):
                 + readchar.key.ENTER
                 # accept schedule being active
                 + readchar.key.ENTER
+                # decline adding another schedule
+                + readchar.key.ENTER
                 # accept create work pool
                 + readchar.key.ENTER
                 +
@@ -4977,9 +5106,13 @@ def test_save_user_inputs_with_rrule_schedule(self):
         assert config["deployments"][1]["name"] == "default"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
-        assert config["deployments"][1]["schedule"]["rrule"] == "FREQ=MINUTELY"
-        assert config["deployments"][1]["schedule"]["timezone"] == "UTC"
-        assert config["deployments"][1]["schedule"]["active"]
+
+        schedule = config["deployments"][1]["schedules"][0]
+        assert schedule == {
+            "rrule": "FREQ=MINUTELY",
+            "timezone": "UTC",
+            "active": True,
+        }
 
     async def test_save_user_inputs_with_actions(self):
         new_deployment_to_save = {
@@ -5131,7 +5264,7 @@ def test_save_user_inputs_overwrite_confirmed(self):
         assert len(config["deployments"]) == 2
         assert config["deployments"][1]["name"] == "default"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
-        assert config["deployments"][1]["schedule"] is None
+        assert config["deployments"][1]["schedules"] == []
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
 
         invoke_and_assert(
@@ -5180,7 +5313,7 @@ def test_save_user_inputs_overwrite_confirmed(self):
         assert len(config["deployments"]) == 2
         assert config["deployments"][1]["name"] == "default"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
-        assert config["deployments"][1]["schedule"]["interval"] == 3600
+        assert config["deployments"][1]["schedules"][0]["interval"] == 3600
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
 
     def test_save_user_inputs_overwrite_rejected_saving_cancelled(self):
@@ -5223,7 +5356,7 @@ def test_save_user_inputs_overwrite_rejected_saving_cancelled(self):
         assert len(config["deployments"]) == 2
         assert config["deployments"][1]["name"] == "default"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
-        assert config["deployments"][1]["schedule"] is None
+        assert config["deployments"][1]["schedules"] == []
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
 
         invoke_and_assert(
@@ -5232,19 +5365,18 @@ def test_save_user_inputs_overwrite_rejected_saving_cancelled(self):
                 # configure new deployment
                 "n"
                 + readchar.key.ENTER
-                # accept schedule
+                # configure schedule
                 + readchar.key.ENTER
                 # select interval schedule
                 + readchar.key.ENTER
                 # enter interval schedule
                 + "3600"
                 + readchar.key.ENTER
-                # accept create work pool
+                # accept schedule being active
                 + readchar.key.ENTER
-                # choose process work pool
+                # decline adding another schedule
                 + readchar.key.ENTER
-                # enter work pool name
-                + "inflatable"
+                # accept existing work pool
                 + readchar.key.ENTER
                 # accept save user inputs
                 + "y"
@@ -5266,7 +5398,7 @@ def test_save_user_inputs_overwrite_rejected_saving_cancelled(self):
         assert len(config["deployments"]) == 2
         assert config["deployments"][1]["name"] == "default"
         assert config["deployments"][1]["entrypoint"] == "flows/hello.py:my_flow"
-        assert config["deployments"][1]["schedule"] is None
+        assert config["deployments"][1]["schedules"] == []
         assert config["deployments"][1]["work_pool"]["name"] == "inflatable"
 
     @pytest.mark.usefixtures("project_dir", "interactive_console")