Schedule a Neon Function

Schedule a Neon Function to pull a public API into Postgres automatically.

Introduction

30 Second Summary

Useful data can quietly become stale overnight. Keeping it current by hand turns a simple update into a daily chore.

In this project, you will build a scheduled job with Neon Functions that pulls records from a public API into a Postgres database. You will use its run history to diagnose a deliberate failure.

What You'll Build

You'll watch fresh rows appear in an empty table while your scheduled Function runs without your input.

By the end of this project, you'll have:

  • A self-updating data flow that pulls fresh public API records into Postgres on a schedule.
  • An HTTP-triggered first run you can demonstrate by showing the first row land.
  • A run history that turns every unattended result into evidence you can inspect.
  • Secret Mission: An optional challenge to push your scheduled data workflow further.

Are there any prerequisites?

You'll need a free Neon account. Basic JavaScript or TypeScript knowledge plus a public API that works without a key covers the rest.

Before We Start

A scheduled job needs a database it can update while you are away. Neon gives your job a home beside the data it keeps fresh.

Before you create the database, you need access to the Neon Console. This step gets your Neon account ready.

In this step, get ready to:
  • Create a Neon account for this project.
  • Reach the Neon Console in your web browser.
  • Identify where the SQL Editor and Functions interfaces fit into the project.
Create your Neon account

A Neon account is your identity for accessing projects through the Console. Your Neon credentials stay inside the sign-up flow.

  • Open the Neon Console in a new browser tab.
  • Choose an email, GitHub, or Google sign-up method.
  • Complete the authorization or email verification requested by your chosen provider.
  • Return to the Neon Console after the sign-up flow finishes.

That is the first piece ready: your Neon account can now reach the Console.

Confirm your Console is ready

How will you use the Console?

The SQL Editor is where you run queries against Postgres. You will use it to create the table that stores data from the public API.

Neon Functions is where your background code runs. Later, a schedule will trigger that code without an HTTP request from you.

Before you check, consider whether your signed-in Console now offers a way to create a project.

  • Locate the New Project action on the Neon Console homepage.

You will see the project creation action on the Console homepage. This confirms that your account is authenticated and ready for the database setup.

Can't reach the Console?

  • Retry the same sign-up method you selected earlier.
  • Check your inbox for an unfinished email verification request.

Ask for help with your Neon sign-in. You can also visit the Neon Discord Server for community help.

Your account is ready in the browser. Next up, you will create the Neon project and prepare an empty table for your scheduled job.

Create Your Database Table

A scheduled job needs a reliable destination before it can store anything. Your database also needs an empty starting point so later rows prove that automation did the work.

In this step, you will create a Neon project with its default Postgres database.

You will use SQL to define api_todos inside the SQL Editor. You will confirm that the table starts with zero rows.

In this step, get ready to:
  • Create sleepy-database with its default Postgres database.
  • Define the api_todos table with four columns.
  • Query api_todos to confirm it contains zero rows.
Create your Neon project

A Neon project holds the database that your scheduled Function uses. The project name keeps this database easy to identify in the Console.

  • Return to the Neon Console in the browser from earlier.
  • Complete the sign-in flow with your existing Neon account.
  • Click New Project.
  • Enter sleepy-database in the Project name field.
  • Choose a Region close to your location.
  • Leave Postgres database enabled under Services.
  • Click Create project.

That is the foundation in place. sleepy-database now contains the default Postgres database that your scheduled Function uses.

Project creation not completing?

  • Check that the project name field contains sleepy-database.
  • Choose a different Region if project creation repeatedly fails.

Ask for help diagnosing Neon project creation. You can also share the non-sensitive error details in the Neon Discord.

Build the api_todos table

The table gives every fetched todo a predictable shape. Its primary key also protects the database from storing the same source record twice.

  • Select Postgres database in the Neon Console sidebar.
  • Select SQL Editor.

You'll see a query editor connected to your project's default database.

  • Place the table definition in the editor by pasting this SQL:
-- Create the destination table for API todo records
create table api_todos (
  source_id integer primary key,
  title text not null,
  completed boolean not null,
  fetched_at timestamptz not null default now()
);

What does this SQL do?

  • The source_id column stores each API record's numeric ID. Its primary key prevents the same ID from appearing twice.
  • The title column stores the todo text. Its constraint requires every row to include a title.
  • The completed column records the todo's completion state. Its constraint requires every row to include that state.
  • The fetched_at column records when a row reaches the database. Its default uses the insertion time automatically.
  • Click Run above the editor.

You'll see a successful result for the table creation query. Your default database now contains api_todos with all four required columns.

Table query not succeeding?

  • Compare each column name against the SQL block above.
  • Check that every opening parenthesis has a matching closing parenthesis.
  • Confirm that the final column does not end with a comma.

Help me fix my api_todos table query.

Confirm the table is empty

The empty starting state gives you a clean baseline. Every row added later can then be traced to the Function.

  • Replace the current SQL with this query:
-- Check whether the new table contains any rows
select * from api_todos;

What does this query check?

This query retrieves every row currently stored in api_todos. The result gives you the baseline that later Function runs change.

Before you run this query, how many data rows do you expect to see in a table you just created?

  • Click Run to check the table's starting state.

You'll see the four column headers above an empty result area. The query result contains zero rows.

Seeing rows or no result?

  • Confirm the Neon Console is showing sleepy-database.
  • Confirm the SQL Editor is connected to the project's default database.
  • Run the query again if the results area remains blank.

Help me verify why api_todos is not showing an empty result.

✔️ Awesome, I've got everything!

Your project is ready. Your table exists with a verified zero-row baseline.

ⓧ I'd like to double check the full code

  • Compare the SQL in your editor with this full reference:
-- Create the destination table for API todo records
create table api_todos (
  source_id integer primary key,
  title text not null,
  completed boolean not null,
  fetched_at timestamptz not null default now()
);

-- Confirm the table starts empty
select * from api_todos;

That is your clean baseline locked in. Next, you will write the Function that fetches public data and inserts the first row.

Build and Test the Function

Your api_todos table is ready inside Postgres. Right now it stays empty because nothing is loading data into it.

A Neon Function packages the fetch and database write into one endpoint. Once that endpoint succeeds manually, it is ready for a schedule.

The handler will pull one todo from a public API. It will upsert that todo into your empty table.

In this step, get ready to:
  • Create scheduled-api-loader with access to the project database.
  • Add a handler that fetches one todo and upserts it into api_todos.
  • Invoke the HTTP endpoint to insert the first row.
Create the Neon Function

Functions belong to a specific database branch. Connecting this Function to the branch that contains api_todos gives its handler access to the table.

  • Select Functions in the left sidebar.
  • Choose the creation control in the upper-right corner.
  • Enter scheduled-api-loader as the Function name.
  • Set the branch connection to the branch that holds the default database for sleepy-database.
  • Keep the default Postgres database selected.
  • Create the Function.

You will see the Function source editor for scheduled-api-loader. The database connection is now available to its handler.

Add the loader handler

An upsert inserts a missing row or updates the existing row with the same key. This keeps repeated runs from creating duplicate copies of todo 1.

  • Replace the starter handler in the Function editor with the code below:
import { attachDatabasePool } from '@neon/functions';
import { Pool } from 'pg';

// Reuse one database pool across Function requests.
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
attachDatabasePool(pool);

export default async function handler(request: Request) {
  // Load the todo that each run writes into Postgres.
  const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
  if (!response.ok) {
    throw new Error(`API request failed with status ${response.status}`);
  }

  // Keep the three API fields used by the table.
  const todo: { id: number; title: string; completed: boolean } = await response.json();

  // Insert the todo or refresh its existing row.
  await pool.query(
    `INSERT INTO api_todos (source_id, title, completed, fetched_at)
     VALUES ($1, $2, $3, NOW())
     ON CONFLICT (source_id) DO UPDATE SET
       title = EXCLUDED.title,
       completed = EXCLUDED.completed,
       fetched_at = NOW()`,
    [todo.id, todo.title, todo.completed],
  );

  return Response.json({ ok: true, todo });
}

What Does This Code Do?

  • The database pool uses the Function's injected DATABASE_URL to reach the connected branch.
  • The fetch request loads one todo from https://jsonplaceholder.typicode.com/todos/1.
  • The conflict rule uses source_id to update the existing row during later runs.
  • The database query sets fetched_at to the time of the latest successful run.
  • Deploy the Function by clicking Deploy.
  • Wait until the deployment status reads completed.

Deployment Not Completing?

Check that both import statements remain at the top of the handler. Confirm that every closing brace from the code block is present.

If the deployment still does not complete, help me diagnose my Neon Function deployment.

That is the Function live. It can now fetch the todo and write to your connected database whenever its endpoint receives a request.

✔️ Awesome, I've got everything!

Great. Double-check that the Function has finished deploying.

ⓧ I'd like to double check the full code

import { attachDatabasePool } from '@neon/functions';
import { Pool } from 'pg';

// Reuse one database pool across Function requests.
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
attachDatabasePool(pool);

export default async function handler(request: Request) {
  // Load the todo that each run writes into Postgres.
  const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
  if (!response.ok) {
    throw new Error(`API request failed with status ${response.status}`);
  }

  // Keep the three API fields used by the table.
  const todo: { id: number; title: string; completed: boolean } = await response.json();

  // Insert the todo or refresh its existing row.
  await pool.query(
    `INSERT INTO api_todos (source_id, title, completed, fetched_at)
     VALUES ($1, $2, $3, NOW())
     ON CONFLICT (source_id) DO UPDATE SET
       title = EXCLUDED.title,
       completed = EXCLUDED.completed,
       fetched_at = NOW()`,
    [todo.id, todo.title, todo.completed],
  );

  return Response.json({ ok: true, todo });
}
Invoke the endpoint manually

The Function has a public invocation URL. Loading that URL sends one request through the same handler that a future schedule will use.

  • Copy the invocation URL shown for scheduled-api-loader: your function invocation URL.
  • Open a new browser tab.

Before you invoke the Function, do you expect the empty table to gain a row?

  • Paste your function invocation URL into the browser address bar.
  • Press Enter.

You will see a JSON response where ok is true. The response also contains the todo returned by the API.

Endpoint Not Returning JSON?

Confirm that you copied the invocation URL from scheduled-api-loader. Check that the Function deployment still shows completed.

If the request still fails, help me troubleshoot my Function invocation.

Confirm the row in Postgres

The endpoint response proves that the handler completed. A table query proves that its database write also worked.

  • Switch back to the SQL Editor from earlier.

Before you run this query, how many rows do you expect to see after one manual invocation?

  • Check the contents of api_todos by running this query:
-- Confirm that the manual Function invocation inserted a row.
SELECT source_id, title, completed, fetched_at
FROM api_todos;

What Does This Query Check?

The query returns every column from api_todos. The result shows the API data alongside the time when your Function stored it.

You will see one row in the results. Its source_id, title, and completed values came from the public API.

Table Still Empty?

Return to the Function response and confirm that ok is true. Confirm that the Function is connected to the same database where you created api_todos.

If the table remains empty, help me trace the missing database row.

  • Return to the Functions interface from earlier.
  • Select scheduled-api-loader in the Functions list.

You have completed the first full data-loading loop. Your Function now turns an external todo into a Postgres row on demand.

Next up, you will attach a schedule so Neon can run this loader without you.

Schedule the Function

The scheduled-api-loader Neon Function already pulls a public todo into Postgres when you call it. The remaining problem is timing because a background job still needs something to start each run.

An enabled Function Trigger moves that timer into Neon. You will attach a five-minute schedule and observe the first invocation that starts without another HTTP request from you.

In this step, get ready to:
  • Open the trigger controls for scheduled-api-loader.
  • Create an enabled schedule that runs every five minutes.
  • Observe the first successful scheduled invocation.
Open the schedule controls

A Function Trigger belongs to the same branch as its function. It tells Neon when to invoke the deployed handler.

  • Locate scheduled-api-loader in the Functions interface from earlier.
  • Click the ⋮ menu beside scheduled-api-loader.
  • Select Manage Triggers.
  • Click Create trigger.
  • Choose Schedule under Trigger type.

Why Schedule Inside Neon?

A Function Trigger keeps its timer outside the Postgres compute. It can invoke the function while the database uses scale to zero.

The pg_cron extension runs schedules inside the Postgres compute. That approach requires the compute to remain active for each scheduled job.

Configure the five-minute schedule

A cron schedule describes recurring time with five numeric fields. The expression */5 * * * * runs the function at every five-minute boundary in UTC.

  • Enter api-todo-refresh in Trigger name.
  • Leave Function path set to /.
  • Enter */5 * * * * in Cron schedule.
  • Confirm Timezone displays UTC.
  • Leave Enable trigger switched on.
  • Click Create trigger.

You should see api-todo-refresh in the trigger panel with */5 * * * *.

Its next run should have a future UTC timestamp. Its enabled state should be on.

That is the automatic caller connected. scheduled-api-loader now has a timer managed by Neon.

Schedule Not Listed?

  • Check that api-todo-refresh is unique on this branch.
  • Check that Cron schedule contains exactly */5 * * * *.
  • Confirm Enable trigger is switched on.

Get help checking my Neon schedule. You can also ask the Neon community.

Watch the first scheduled run

The trigger panel exposes the upcoming run time. That timestamp gives you a visible way to follow the first automatic handoff.

This check can take up to five minutes because the trigger waits for the next five-minute boundary.

Before the scheduled time arrives, do you expect Neon to need another HTTP request from you?

  • Read the next run time shown for api-todo-refresh.
  • Wait until that scheduled time has passed.
  • Refresh the trigger panel.

The next run time should move forward to another five-minute boundary. That change proves Neon processed the scheduled occurrence without your manual request.

  • Return to scheduled-api-loader in Functions.
  • Inspect the latest platform logs around the scheduled timestamp.

You should see invoke begin followed by invoke end under neon.function.request.

Those paired lines confirm that the scheduled invocation completed successfully.

No Scheduled Invocation?

  • Confirm the trigger remains enabled in Manage Triggers.
  • Compare the current UTC time with the next run time in the trigger panel.
  • Refresh the function logs after the next five-minute boundary.

Help me find my missing scheduled invocation.

That is the handoff complete. Your function now has a successful run that started on Neon’s clock.

Your scheduler is now calling scheduled-api-loader on its own. Next up, you will query api_todos for database proof.

Verify the Scheduled Run

Your schedule has now had time to trigger scheduled-api-loader in Neon Functions. The next job is proving that execution reached Postgres without a manual HTTP request.

The fetched_at value shows when the upsert last touched the row. The function's run history ties that time to api-todo-refresh.

In this step, get ready to:
  • Query api_todos to capture its refreshed fetched_at timestamp.
  • Match the timestamp to a successful scheduled execution in the function's run history.
Query the refreshed row

The schedule updates the existing row because source_id is the conflict key. Each successful upsert replaces fetched_at with the latest fetch time.

  • In the Neon Console sidebar, select SQL Editor.
  • Replace the editor contents with this query:
SELECT source_id, title, completed, fetched_at
FROM api_todos;

What does this query check?

  • The query returns the current row from api_todos.
  • The fetched_at column records when the latest upsert refreshed that row.

Before you run the query, do you expect fetched_at to show the manual invocation time or a later scheduled time?

  • Click the run control above the SQL Editor.

You should see one row with a fetched_at value later than the manual HTTP invocation. That later time matters because you have not sent another manual request.

  • Record the timestamp shown in fetched_at here: your fetched_at timestamp.

That is the proof you were after. The schedule refreshed api_todos while you left it alone.

Still seeing the old timestamp?

  • Confirm sleepy-database is the active project if the result is empty.
  • Wait for the next five-minute run if fetched_at still matches the manual invocation.
  • Help me diagnose why my scheduled row is missing.
Match the run to the schedule

A database timestamp shows that data changed. Run history identifies which execution changed it.

Before you open the run history, which type of execution do you expect to line up with your fetched_at timestamp?

  • Return to the Functions interface from earlier.
  • Select scheduled-api-loader.
  • Open the function's run history.

You should see the earlier manual execution. You should also see at least one successful scheduled execution from api-todo-refresh.

  • Find the successful scheduled execution associated with api-todo-refresh.
  • Compare its run time with your fetched_at timestamp.

The scheduled execution time should align with your fetched_at timestamp. This links the database refresh to an execution you did not trigger.

Scheduled run missing from history?

  • Confirm api-todo-refresh is still enabled if only the manual execution appears.
  • Wait until the next five-minute interval if the latest scheduled execution has not appeared.
  • Help me find the missing scheduled execution.

You now have complete evidence for the background job. Next up, you will make the job fail on purpose so its logs can guide the fix.

Debug and Restore the Function

Your scheduled Neon Function now refreshes Postgres without a manual request.

Background work can fail while nobody is watching. A Function Trigger needs an observable recovery path.

You'll create that failure on purpose. You'll restore the handler after its cause appears in the logs.

In this step, get ready to:
  • Create a deliberate runtime failure in scheduled-api-loader.
  • Trace the failed api-todo-refresh execution through the function logs.
  • Confirm the restored handler succeeds on its next scheduled execution.
Create a scheduled failure

The test changes one character inside the existing fetch target. Copying the handler first captures its exact starting state.

  • Return from the open run history to the Function page for scheduled-api-loader.
  • Open the handler editor from the Function page.
  • Select all of the handler code.
  • Copy the selected code with Cmd+C (macOS) or Ctrl+C (Windows).
  • Locate https://jsonplaceholder.typicode.com/todos/1 inside the existing fetch call.
  • Delete the first character from that URL.
  • Deploy the edited handler with the function editor's deployment control.
  • Watch the deployment state until it completes.

The deployed handler now contains the one-character change.

The schedule runs every 5 minutes, so the next execution can take up to 5 minutes to appear.

Before you return to the run history, do you think the database upsert can run after the fetch call receives that changed address?

  • Return to the run history for scheduled-api-loader.
  • Watch for the next execution from api-todo-refresh.

You'll see that scheduled execution fail before it reaches the database upsert. This is the intended shortfall.

Run Still Succeeds?

  • Confirm the first character is missing from the URL in the deployed handler.
  • Confirm api-todo-refresh is still enabled.
  • Wait for the following 5-minute execution if the run you saw started before deployment completed.
  • Ask for help with the scheduled failure: Why did my deliberately broken Neon Function still succeed on its scheduled run?
Trace the failure through the logs

Run history shows which execution failed. Function logs reveal where the handler stopped.

  • Return to the Functions list in the Neon Console.
  • Open the menu beside scheduled-api-loader.
  • Select View logs.
  • Confirm the Functions tab is active on the Monitoring page.
  • Enter scheduled-api-loader in the service name field.
  • Select the 15m time range.
  • Expand the log row that matches the failed execution's timestamp.

You'll see an error connected to the malformed fetch address near the failed invocation. This proves the scheduler reached the handler before the handler stopped.

How Do the Log Lines Fit Together?

  • The neon.function.request scope contains platform records around each invocation.
  • An invoke begin line marks the start of an invocation.
  • An invoke end line marks its finish.
  • The nearby error exposes the handler problem associated with this failed run.
Restore the handler and watch it recover

Your clipboard copy preserves the full working handler from before the test. Restoring that exact copy removes the deliberate change without rebuilding the function from memory.

  • Return to the Function page for scheduled-api-loader.
  • Open the handler editor.
  • Select all of the broken handler code.
  • Paste the copied handler with Cmd+V (macOS) or Ctrl+V (Windows).
  • Deploy the restored handler with the function editor's deployment control.
  • Watch the deployment state until it completes.

What Does the Restored Handler Do?

  • The handler fetches one todo from the public API at https://jsonplaceholder.typicode.com/todos/1.
  • It reads id from the response.
  • It reads title from the response.
  • It reads completed from the response.
  • Its upsert writes one row into api_todos with source_id as the conflict key.
  • A successful conflict update refreshes fetched_at to the current time.

The schedule still runs every 5 minutes. The next result can take up to 5 minutes to arrive.

Before you check the run history, do you expect the next scheduled execution to repeat the failure or complete successfully?

  • Return to the run history for scheduled-api-loader.
  • Watch for the next execution from api-todo-refresh.

You'll see the next scheduled execution succeed after the deliberate failure.

That's the recovery loop complete. Your scheduled loader can now fail visibly and return to healthy database updates.

Still Seeing a Failed Run?

  • Confirm the restored handler contains https://jsonplaceholder.typicode.com/todos/1.
  • Deploy the copied backup again if the restored deployment did not complete.
  • Wait for an execution that started after the restored deployment completed.
  • Ask for help with the restored run: Why is my restored Neon Function still failing on its schedule?

Secret mission

Build a Failure Recovery Record

Turn the failed execution and the recovered execution into a compact incident record. Use the run history to prove when the failure happened. Use the logs to show how the scheduled job recovered.

Clean Up Your Resources

Clean Up Your Resources

Your Neon project stays within the free plan at this size. Decide whether to leave api-todo-refresh enabled, pause it for later, or delete the schedule so it stops polling.

Resources you used:

  • The enabled api-todo-refresh schedule that invokes scheduled-api-loader every 5 minutes.

Keep everything running

No action is needed. Choose this if you want the schedule to keep refreshing your table while you continue testing the project.

  • Leave api-todo-refresh enabled so scheduled executions continue every 5 minutes.
  • Review the run history occasionally to confirm scheduled executions still succeed.
  • Monitor your free plan allowance while the schedule continues polling.

Pause - I'll come back to this later

Pause the schedule to stop new executions while preserving its configuration. Your Function code and database table remain ready for your return.

  • Return to scheduled-api-loader in the Neon Console.
  • Open the attached api-todo-refresh schedule.
  • Turn off the schedule's enabled setting.

You'll see that api-todo-refresh is inactive. Scheduled executions stop until you enable it again.

Delete - I don't want to use this again

Removing the schedule stops future background runs. Your scheduled-api-loader code and api_todos Postgres table remain in sleepy-database.

  • Return to scheduled-api-loader in the Neon Console.
  • Open the attached api-todo-refresh schedule.
  • Choose the delete action for the schedule.
  • Confirm the deletion when Neon asks you to approve it.

You'll no longer see api-todo-refresh attached to the Function. The run history remains your evidence that the background job succeeded and recovered from a deliberate failure.

Nice Work!

Nice Work!

You did it! Your Neon Postgres database now refreshes itself through a scheduled Neon Function.

The table holds data from an unattended run. The run history proves you can trace a failure before confirming the next scheduled execution succeeds.

You've learned how to:

  • Create an api_todos table that stores public API data. Record the time of each refresh through fetched_at.
  • Build a Function handler that fetches data from a public API. Use an upsert to refresh the existing row without creating duplicates.
  • Automate refreshes through a scheduled execution. Confirm unattended results through the run history. Trace a deliberate failure through the function logs before restoring the handler.
  • Secret Mission: Complete an optional challenge that extends your self-refreshing database with another scheduled workflow skill.

Ready to quiz yourself?